detached f0438ea

refactor(daemon): fence transferable pane resizes

commit
f0438ea40a692fb01306b6ef6d7fd664efbb3db1
author
brice · · 2026-07-28 04:46 UTC
parent
64cc0d8
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
11 files · +1607 -193
M internal/usecase/daemon/copymode_test.go +2 -1
@@ -845,9 +845,10 @@ func TestFloatingExitClearsCopyModeBeforeRepaint(t *testing.T) {
845845 awaitFrame(t, sends, ports.MsgOutput)
846846 tb.mu.Lock()
847847 generation := tb.floating.generation
848+ publishPaneOwner(fp, sess, tb, generation)
848849 tb.mu.Unlock()
849850
850- d.reapFloating(sess, tb, fp, generation)
851+ d.reapInstalledFloating(fp)
851852
852853 ac.overlays.copyMu.Lock()
853854 require.Nil(t, ac.overlays.copyMode)
@@ -845,9 +845,10 @@ func TestFloatingExitClearsCopyModeBeforeRepaint(t *testing.T) {
845 awaitFrame(t, sends, ports.MsgOutput)845 awaitFrame(t, sends, ports.MsgOutput)
846 tb.mu.Lock()846 tb.mu.Lock()
847 generation := tb.floating.generation847 generation := tb.floating.generation
848+ publishPaneOwner(fp, sess, tb, generation)
848 tb.mu.Unlock()849 tb.mu.Unlock()
849 850
850- d.reapFloating(sess, tb, fp, generation)851+ d.reapInstalledFloating(fp)
851 852
852 ac.overlays.copyMu.Lock()853 ac.overlays.copyMu.Lock()
853 require.Nil(t, ac.overlays.copyMode)854 require.Nil(t, ac.overlays.copyMode)
M internal/usecase/daemon/daemon.go +4 -0
@@ -125,6 +125,10 @@ type Daemon struct {
125125 // and before coordinator epoch admission. It is a deterministic regression
126126 // seam for stale resize publication.
127127 beforeSessionResizePublication func()
128+ // beforeResizeOwnerPostEffect pauses after a resize's optimistic owner check
129+ // and immediately before a post-commit effect is published. Tests use it to
130+ // move a pane through the ordered resize fences at the former TOCTOU window.
131+ beforeResizeOwnerPostEffect func(resizeOwnerPostEffect)
128132 // afterSnatchOverlayFamily is a deterministic structural lock seam. It runs
129133 // after one overlay-family mutex is released and before the next is taken.
130134 afterSnatchOverlayFamily func(string)
@@ -125,6 +125,10 @@ type Daemon struct {
125 // and before coordinator epoch admission. It is a deterministic regression125 // and before coordinator epoch admission. It is a deterministic regression
126 // seam for stale resize publication.126 // seam for stale resize publication.
127 beforeSessionResizePublication func()127 beforeSessionResizePublication func()
128+ // beforeResizeOwnerPostEffect pauses after a resize's optimistic owner check
129+ // and immediately before a post-commit effect is published. Tests use it to
130+ // move a pane through the ordered resize fences at the former TOCTOU window.
131+ beforeResizeOwnerPostEffect func(resizeOwnerPostEffect)
128 // afterSnatchOverlayFamily is a deterministic structural lock seam. It runs132 // afterSnatchOverlayFamily is a deterministic structural lock seam. It runs
129 // after one overlay-family mutex is released and before the next is taken.133 // after one overlay-family mutex is released and before the next is taken.
130 afterSnatchOverlayFamily func(string)134 afterSnatchOverlayFamily func(string)
M internal/usecase/daemon/floating.go +48 -20
@@ -42,6 +42,14 @@ func (tb *tab) beginFloatingWarmLocked(desiredVisible bool) uint64 {
4242 return tb.floating.generation
4343 }
4444
45+// floatingTransferableLocked reports whether a tab's floating lifecycle can
46+// cross an ownership commit. A warming Open remains registered to its source
47+// session until installation or failure, so moves must reject only that state.
48+// The caller must hold tb.mu.
49+func (tb *tab) floatingTransferableLocked() bool {
50+ return tb != nil && tb.floating.state != floatingWarming
51+}
52+
4553 // toggleFloatingLocked applies the user action. Its result says whether a new
4654 // PTY launch is required and returns the generation associated with that launch
4755 // or current slot.
@@ -417,8 +425,10 @@ func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLau
417425 }
418426 ownedByPane = true
419427 // The reader may run as soon as install returns; make its exit policy
420- // immutable before publishing the pane to the slot.
421- p.onExit = func() { d.reapFloating(sess, tb, p, generation) }
428+ // immutable before publishing the pane to the slot. Installed panes resolve
429+ // their owner dynamically so a transferred tab never reaps through source
430+ // session pointers captured by its launch.
431+ p.onExit = func() { d.reapInstalledFloating(p) }
422432 d.installFloating(sess, tb, p, generation)
423433 }
424434
@@ -478,26 +488,44 @@ func (d *Daemon) installFloating(sess *session, tb *tab, p *pane, generation uin
478488 }
479489 }
480490
481-// reapFloating ignores old readers and restores the background only when the
482-// exiting popup was visible.
483-func (d *Daemon) reapFloating(sess *session, tb *tab, p *pane, generation uint64) {
484- tb.mu.Lock()
485- visible := tb.floating.state == floatingVisible && tb.floating.pane == p && tb.floating.generation == generation
486- cleared := tb.clearFloatingLocked(p, generation)
487- tb.mu.Unlock()
488- if !cleared {
491+// reapInstalledFloating resolves the pane's current immutable owner on every
492+// attempt. If ownership changes before the tab lock is acquired, retrying the
493+// lookup routes exit through the destination. The owner pointer and floating
494+// slot generation are then checked together under tb.mu so an old exit can
495+// never clear a reused slot.
496+func (d *Daemon) reapInstalledFloating(p *pane) {
497+ if p == nil {
489498 return
490499 }
491- closeFloatingPane(p)
492- sess.mu.Lock()
493- ac := sess.client
494- sess.mu.Unlock()
495- if ac != nil {
496- ac.pruneCaptureFrames(p)
497- }
498- copyCleared := ac != nil && ac.overlays.clearCopyModeForPane(p)
499- if ac != nil && (visible || copyCleared) {
500- d.invalidateRender(sess, ac, true, "floating.go")
500+ for {
501+ owner := p.ownerSnapshot()
502+ if owner == nil || owner.session == nil || owner.tab == nil || owner.floatingSlotGeneration == 0 {
503+ return
504+ }
505+ sess, tb, generation := owner.session, owner.tab, owner.floatingSlotGeneration
506+ tb.mu.Lock()
507+ if p.ownerSnapshot() != owner {
508+ tb.mu.Unlock()
509+ continue
510+ }
511+ visible := tb.floating.state == floatingVisible && tb.floating.pane == p && tb.floating.generation == generation
512+ cleared := tb.clearFloatingLocked(p, generation)
513+ tb.mu.Unlock()
514+ if !cleared {
515+ return
516+ }
517+ closeFloatingPane(p)
518+ sess.mu.Lock()
519+ ac := sess.client
520+ sess.mu.Unlock()
521+ if ac != nil {
522+ ac.pruneCaptureFrames(p)
523+ }
524+ copyCleared := ac != nil && ac.overlays.clearCopyModeForPane(p)
525+ if ac != nil && (visible || copyCleared) {
526+ d.invalidateRender(sess, ac, true, "floating.go")
527+ }
528+ return
501529 }
502530 }
503531
@@ -42,6 +42,14 @@ func (tb *tab) beginFloatingWarmLocked(desiredVisible bool) uint64 {
42 return tb.floating.generation42 return tb.floating.generation
43 }43 }
44 44
45+// floatingTransferableLocked reports whether a tab's floating lifecycle can
46+// cross an ownership commit. A warming Open remains registered to its source
47+// session until installation or failure, so moves must reject only that state.
48+// The caller must hold tb.mu.
49+func (tb *tab) floatingTransferableLocked() bool {
50+ return tb != nil && tb.floating.state != floatingWarming
51+}
52+
45 // toggleFloatingLocked applies the user action. Its result says whether a new53 // toggleFloatingLocked applies the user action. Its result says whether a new
46 // PTY launch is required and returns the generation associated with that launch54 // PTY launch is required and returns the generation associated with that launch
47 // or current slot.55 // or current slot.
@@ -417,8 +425,10 @@ func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLau
417 }425 }
418 ownedByPane = true426 ownedByPane = true
419 // The reader may run as soon as install returns; make its exit policy427 // The reader may run as soon as install returns; make its exit policy
420- // immutable before publishing the pane to the slot.428+ // immutable before publishing the pane to the slot. Installed panes resolve
421- p.onExit = func() { d.reapFloating(sess, tb, p, generation) }429+ // their owner dynamically so a transferred tab never reaps through source
430+ // session pointers captured by its launch.
431+ p.onExit = func() { d.reapInstalledFloating(p) }
422 d.installFloating(sess, tb, p, generation)432 d.installFloating(sess, tb, p, generation)
423 }433 }
424 434
@@ -478,26 +488,44 @@ func (d *Daemon) installFloating(sess *session, tb *tab, p *pane, generation uin
478 }488 }
479 }489 }
480 490
481-// reapFloating ignores old readers and restores the background only when the491+// reapInstalledFloating resolves the pane's current immutable owner on every
482-// exiting popup was visible.492+// attempt. If ownership changes before the tab lock is acquired, retrying the
483-func (d *Daemon) reapFloating(sess *session, tb *tab, p *pane, generation uint64) {493+// lookup routes exit through the destination. The owner pointer and floating
484- tb.mu.Lock()494+// slot generation are then checked together under tb.mu so an old exit can
485- visible := tb.floating.state == floatingVisible && tb.floating.pane == p && tb.floating.generation == generation495+// never clear a reused slot.
486- cleared := tb.clearFloatingLocked(p, generation)496+func (d *Daemon) reapInstalledFloating(p *pane) {
487- tb.mu.Unlock()497+ if p == nil {
488- if !cleared {
489 return498 return
490 }499 }
491- closeFloatingPane(p)500+ for {
492- sess.mu.Lock()501+ owner := p.ownerSnapshot()
493- ac := sess.client502+ if owner == nil || owner.session == nil || owner.tab == nil || owner.floatingSlotGeneration == 0 {
494- sess.mu.Unlock()503+ return
495- if ac != nil {504+ }
496- ac.pruneCaptureFrames(p)505+ sess, tb, generation := owner.session, owner.tab, owner.floatingSlotGeneration
497- }506+ tb.mu.Lock()
498- copyCleared := ac != nil && ac.overlays.clearCopyModeForPane(p)507+ if p.ownerSnapshot() != owner {
499- if ac != nil && (visible || copyCleared) {508+ tb.mu.Unlock()
500- d.invalidateRender(sess, ac, true, "floating.go")509+ continue
510+ }
511+ visible := tb.floating.state == floatingVisible && tb.floating.pane == p && tb.floating.generation == generation
512+ cleared := tb.clearFloatingLocked(p, generation)
513+ tb.mu.Unlock()
514+ if !cleared {
515+ return
516+ }
517+ closeFloatingPane(p)
518+ sess.mu.Lock()
519+ ac := sess.client
520+ sess.mu.Unlock()
521+ if ac != nil {
522+ ac.pruneCaptureFrames(p)
523+ }
524+ copyCleared := ac != nil && ac.overlays.clearCopyModeForPane(p)
525+ if ac != nil && (visible || copyCleared) {
526+ d.invalidateRender(sess, ac, true, "floating.go")
527+ }
528+ return
501 }529 }
502 }530 }
503 531
M internal/usecase/daemon/floating_open_test.go +10 -7
@@ -73,23 +73,26 @@ func (f *contextAwareFloatingFactory) Open(ctx context.Context, _ string, _ []st
7373
7474 func TestFloatingSuccessfulOpenTransfersContextOwnershipToPane(t *testing.T) {
7575 teardowns := []struct {
76- name string
77- teardown func(*Daemon, *session, *tab)
76+ name string
77+ desiredVisible bool
78+ teardown func(*Daemon, *session, *tab)
7879 }{
7980 {
80- name: "floating pane",
81+ name: "hidden floating pane",
8182 teardown: func(d *Daemon, _ *session, tb *tab) {
8283 d.teardownFloating(tb, nil)
8384 },
8485 },
8586 {
86- name: "tab",
87+ name: "visible tab",
88+ desiredVisible: true,
8789 teardown: func(_ *Daemon, _ *session, tb *tab) {
8890 tb.closeAllPanes()
8991 },
9092 },
9193 {
92- name: "session",
94+ name: "visible session",
95+ desiredVisible: true,
9396 teardown: func(d *Daemon, sess *session, _ *tab) {
9497 require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
9598 },
@@ -109,7 +112,7 @@ func TestFloatingSuccessfulOpenTransfersContextOwnershipToPane(t *testing.T) {
109112 d.sessions[sess.id] = sess
110113 d.mu.Unlock()
111114 tb.mu.Lock()
112- generation := tb.beginFloatingWarmLocked(true)
115+ generation := tb.beginFloatingWarmLocked(tt.desiredVisible)
113116 tb.mu.Unlock()
114117
115118 d.openAndInstallFloating(sess, tb, floatingLaunchSpec{
@@ -129,7 +132,7 @@ func TestFloatingSuccessfulOpenTransfersContextOwnershipToPane(t *testing.T) {
129132 sess.mu.Lock()
130133 sess.client = &attachedClient{}
131134 sess.mu.Unlock()
132- require.True(t, d.paneRenderable(sess, tb, floating), "installed visible popup must remain renderable")
135+ require.Equal(t, tt.desiredVisible, d.paneRenderable(sess, tb, floating), "installed popup renderability must follow its retained visibility")
133136 sess.mu.Lock()
134137 sess.client = nil
135138 sess.mu.Unlock()
@@ -73,23 +73,26 @@ func (f *contextAwareFloatingFactory) Open(ctx context.Context, _ string, _ []st
73 73
74 func TestFloatingSuccessfulOpenTransfersContextOwnershipToPane(t *testing.T) {74 func TestFloatingSuccessfulOpenTransfersContextOwnershipToPane(t *testing.T) {
75 teardowns := []struct {75 teardowns := []struct {
76- name string76+ name string
77- teardown func(*Daemon, *session, *tab)77+ desiredVisible bool
78+ teardown func(*Daemon, *session, *tab)
78 }{79 }{
79 {80 {
80- name: "floating pane",81+ name: "hidden floating pane",
81 teardown: func(d *Daemon, _ *session, tb *tab) {82 teardown: func(d *Daemon, _ *session, tb *tab) {
82 d.teardownFloating(tb, nil)83 d.teardownFloating(tb, nil)
83 },84 },
84 },85 },
85 {86 {
86- name: "tab",87+ name: "visible tab",
88+ desiredVisible: true,
87 teardown: func(_ *Daemon, _ *session, tb *tab) {89 teardown: func(_ *Daemon, _ *session, tb *tab) {
88 tb.closeAllPanes()90 tb.closeAllPanes()
89 },91 },
90 },92 },
91 {93 {
92- name: "session",94+ name: "visible session",
95+ desiredVisible: true,
93 teardown: func(d *Daemon, sess *session, _ *tab) {96 teardown: func(d *Daemon, sess *session, _ *tab) {
94 require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))97 require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
95 },98 },
@@ -109,7 +112,7 @@ func TestFloatingSuccessfulOpenTransfersContextOwnershipToPane(t *testing.T) {
109 d.sessions[sess.id] = sess112 d.sessions[sess.id] = sess
110 d.mu.Unlock()113 d.mu.Unlock()
111 tb.mu.Lock()114 tb.mu.Lock()
112- generation := tb.beginFloatingWarmLocked(true)115+ generation := tb.beginFloatingWarmLocked(tt.desiredVisible)
113 tb.mu.Unlock()116 tb.mu.Unlock()
114 117
115 d.openAndInstallFloating(sess, tb, floatingLaunchSpec{118 d.openAndInstallFloating(sess, tb, floatingLaunchSpec{
@@ -129,7 +132,7 @@ func TestFloatingSuccessfulOpenTransfersContextOwnershipToPane(t *testing.T) {
129 sess.mu.Lock()132 sess.mu.Lock()
130 sess.client = &attachedClient{}133 sess.client = &attachedClient{}
131 sess.mu.Unlock()134 sess.mu.Unlock()
132- require.True(t, d.paneRenderable(sess, tb, floating), "installed visible popup must remain renderable")135+ require.Equal(t, tt.desiredVisible, d.paneRenderable(sess, tb, floating), "installed popup renderability must follow its retained visibility")
133 sess.mu.Lock()136 sess.mu.Lock()
134 sess.client = nil137 sess.client = nil
135 sess.mu.Unlock()138 sess.mu.Unlock()
M internal/usecase/daemon/floating_test.go +83 -3
@@ -131,6 +131,30 @@ func TestFloatingSlotTransitions(t *testing.T) {
131131 }
132132 }
133133
134+func TestFloatingTransferabilityRejectsOnlyWarmingLaunch(t *testing.T) {
135+ tests := []struct {
136+ name string
137+ state floatingState
138+ want bool
139+ }{
140+ {name: "uninitialized", state: floatingUninitialized, want: true},
141+ {name: "warming", state: floatingWarming, want: false},
142+ {name: "hidden installed", state: floatingHidden, want: true},
143+ {name: "visible installed", state: floatingVisible, want: true},
144+ }
145+ for _, tt := range tests {
146+ t.Run(tt.name, func(t *testing.T) {
147+ tb := &tab{floating: floatingSlot{state: tt.state, generation: 7}}
148+ tb.mu.Lock()
149+ got := tb.floatingTransferableLocked()
150+ tb.mu.Unlock()
151+ require.Equal(t, tt.want, got)
152+ require.Equal(t, tt.state, tb.floating.state, "validation must not change launch state")
153+ require.Equal(t, uint64(7), tb.floating.generation, "validation must preserve retry generation")
154+ })
155+ }
156+}
157+
134158 func TestFloatingLifecycleCapturesLaunchBeforeOpenAndDoesNotHoldTabLock(t *testing.T) {
135159 pty := portsmocks.NewMockPTY(t)
136160 readerStarted := make(chan struct{})
@@ -272,28 +296,83 @@ func TestFloatingLifecycleStaleSuccessAndOldExitCannotReplaceCurrentSlot(t *test
272296 second := &pane{}
273297 tb.mu.Lock()
274298 g1 := tb.beginFloatingWarmLocked(true)
299+ publishPaneOwner(first, sess, tb, g1)
275300 require.True(t, tb.installFloatingLocked(first, g1))
276301 g2 := tb.clearFloatingLocked(first, g1)
277302 require.True(t, g2)
278303 generation := tb.beginFloatingWarmLocked(true)
279304 require.True(t, tb.installFloatingLocked(second, generation))
280305 tb.mu.Unlock()
281- d.reapFloating(sess, tb, first, g1)
306+ d.reapInstalledFloating(first)
282307 tb.mu.Lock()
283308 require.Same(t, second, tb.floating.pane, "old EOF must not clear replacement")
284309 tb.mu.Unlock()
285310 }
286311
312+func TestFloatingExitUsesCurrentOwnerAfterTabTransfer(t *testing.T) {
313+ factory := &lifetimePTYFactory{}
314+ d := newTestDaemon(t, factory, stubClock{})
315+ tb := newFloatingTestTab(t)
316+ source := &session{id: "source", name: "source", tabs: []*tab{tb}, ctx: t.Context()}
317+ destination := &session{id: "destination", name: "destination", tabs: []*tab{tb}, ctx: t.Context()}
318+
319+ tb.mu.Lock()
320+ generation := tb.beginFloatingWarmLocked(false)
321+ tb.mu.Unlock()
322+ d.openAndInstallFloating(source, tb, floatingLaunchSpec{
323+ sessionName: source.name,
324+ size: domain.Size{Cols: 20, Rows: 8},
325+ geometry: floatingGeometry{Inner: domain.Rect{Width: 20, Height: 8}},
326+ paneStableID: "p_floating",
327+ parentCtx: tb.ctx,
328+ }, generation)
329+
330+ tb.mu.Lock()
331+ floating := tb.floating.pane
332+ tb.mu.Unlock()
333+ require.NotNil(t, floating)
334+ tb.mu.Lock()
335+ floating.mu.Lock()
336+ floating.publishOwnerLocked(destination, tb, generation)
337+ floating.mu.Unlock()
338+ tb.mu.Unlock()
339+
340+ sourceClient := &attachedClient{captureFrames: map[*pane]capturedPaneRenderState{floating: {}}}
341+ sourceClient.initOverlays()
342+ destinationClient := &attachedClient{captureFrames: map[*pane]capturedPaneRenderState{floating: {}}}
343+ destinationClient.initOverlays()
344+ source.client = sourceClient
345+ destination.client = destinationClient
346+
347+ floating.onExit()
348+ d.sessWg.Wait()
349+
350+ tb.mu.Lock()
351+ require.Equal(t, floatingUninitialized, tb.floating.state)
352+ require.Nil(t, tb.floating.pane)
353+ tb.mu.Unlock()
354+ sourceClient.sendMu.Lock()
355+ _, sourceRetained := sourceClient.captureFrames[floating]
356+ sourceClient.sendMu.Unlock()
357+ destinationClient.sendMu.Lock()
358+ _, destinationRetained := destinationClient.captureFrames[floating]
359+ destinationClient.sendMu.Unlock()
360+ require.True(t, sourceRetained, "exit must not clean up the retired source owner")
361+ require.False(t, destinationRetained, "exit must clean up the current destination owner")
362+}
363+
287364 func TestFloatingReapAndTeardownCloseMatchingPaneOnce(t *testing.T) {
288365 pty, _ := newBlockingPTY(t)
289366 tb := newFloatingTestTab(t)
290367 p := newPane(layout.PaneID("floating"), pty, domain.Size{Cols: 10, Rows: 10})
368+ sess := &session{}
291369 tb.mu.Lock()
292370 g := tb.beginFloatingWarmLocked(true)
371+ publishPaneOwner(p, sess, tb, g)
293372 require.True(t, tb.installFloatingLocked(p, g))
294373 tb.mu.Unlock()
295374 d := newTestDaemon(t, nil, stubClock{})
296- d.reapFloating(&session{}, tb, p, g)
375+ d.reapInstalledFloating(p)
297376 d.teardownFloating(tb, nil)
298377 pty.AssertNumberOfCalls(t, "Close", 1)
299378 }
@@ -358,6 +437,7 @@ func TestFloatingEOFRepaintsVisibleSlotOnly(t *testing.T) {
358437 }).Once()
359438 floating := newPane(layout.PaneID("floating"), floatingPTY, domain.Size{Cols: 20, Rows: 8})
360439 generation := tb.beginFloatingWarmLocked(state == floatingVisible)
440+ publishPaneOwner(floating, sess, tb, generation)
361441 require.True(t, tb.installFloatingLocked(floating, generation))
362442 if state == floatingHidden {
363443 require.Equal(t, floatingHidden, tb.floating.state)
@@ -374,7 +454,7 @@ func TestFloatingEOFRepaintsVisibleSlotOnly(t *testing.T) {
374454
375455 reaped := make(chan struct{})
376456 floating.onExit = func() {
377- d.reapFloating(sess, tb, floating, generation)
457+ d.reapInstalledFloating(floating)
378458 close(reaped)
379459 }
380460 d.sessWg.Add(1)
@@ -131,6 +131,30 @@ func TestFloatingSlotTransitions(t *testing.T) {
131 }131 }
132 }132 }
133 133
134+func TestFloatingTransferabilityRejectsOnlyWarmingLaunch(t *testing.T) {
135+ tests := []struct {
136+ name string
137+ state floatingState
138+ want bool
139+ }{
140+ {name: "uninitialized", state: floatingUninitialized, want: true},
141+ {name: "warming", state: floatingWarming, want: false},
142+ {name: "hidden installed", state: floatingHidden, want: true},
143+ {name: "visible installed", state: floatingVisible, want: true},
144+ }
145+ for _, tt := range tests {
146+ t.Run(tt.name, func(t *testing.T) {
147+ tb := &tab{floating: floatingSlot{state: tt.state, generation: 7}}
148+ tb.mu.Lock()
149+ got := tb.floatingTransferableLocked()
150+ tb.mu.Unlock()
151+ require.Equal(t, tt.want, got)
152+ require.Equal(t, tt.state, tb.floating.state, "validation must not change launch state")
153+ require.Equal(t, uint64(7), tb.floating.generation, "validation must preserve retry generation")
154+ })
155+ }
156+}
157+
134 func TestFloatingLifecycleCapturesLaunchBeforeOpenAndDoesNotHoldTabLock(t *testing.T) {158 func TestFloatingLifecycleCapturesLaunchBeforeOpenAndDoesNotHoldTabLock(t *testing.T) {
135 pty := portsmocks.NewMockPTY(t)159 pty := portsmocks.NewMockPTY(t)
136 readerStarted := make(chan struct{})160 readerStarted := make(chan struct{})
@@ -272,28 +296,83 @@ func TestFloatingLifecycleStaleSuccessAndOldExitCannotReplaceCurrentSlot(t *test
272 second := &pane{}296 second := &pane{}
273 tb.mu.Lock()297 tb.mu.Lock()
274 g1 := tb.beginFloatingWarmLocked(true)298 g1 := tb.beginFloatingWarmLocked(true)
299+ publishPaneOwner(first, sess, tb, g1)
275 require.True(t, tb.installFloatingLocked(first, g1))300 require.True(t, tb.installFloatingLocked(first, g1))
276 g2 := tb.clearFloatingLocked(first, g1)301 g2 := tb.clearFloatingLocked(first, g1)
277 require.True(t, g2)302 require.True(t, g2)
278 generation := tb.beginFloatingWarmLocked(true)303 generation := tb.beginFloatingWarmLocked(true)
279 require.True(t, tb.installFloatingLocked(second, generation))304 require.True(t, tb.installFloatingLocked(second, generation))
280 tb.mu.Unlock()305 tb.mu.Unlock()
281- d.reapFloating(sess, tb, first, g1)306+ d.reapInstalledFloating(first)
282 tb.mu.Lock()307 tb.mu.Lock()
283 require.Same(t, second, tb.floating.pane, "old EOF must not clear replacement")308 require.Same(t, second, tb.floating.pane, "old EOF must not clear replacement")
284 tb.mu.Unlock()309 tb.mu.Unlock()
285 }310 }
286 311
312+func TestFloatingExitUsesCurrentOwnerAfterTabTransfer(t *testing.T) {
313+ factory := &lifetimePTYFactory{}
314+ d := newTestDaemon(t, factory, stubClock{})
315+ tb := newFloatingTestTab(t)
316+ source := &session{id: "source", name: "source", tabs: []*tab{tb}, ctx: t.Context()}
317+ destination := &session{id: "destination", name: "destination", tabs: []*tab{tb}, ctx: t.Context()}
318+
319+ tb.mu.Lock()
320+ generation := tb.beginFloatingWarmLocked(false)
321+ tb.mu.Unlock()
322+ d.openAndInstallFloating(source, tb, floatingLaunchSpec{
323+ sessionName: source.name,
324+ size: domain.Size{Cols: 20, Rows: 8},
325+ geometry: floatingGeometry{Inner: domain.Rect{Width: 20, Height: 8}},
326+ paneStableID: "p_floating",
327+ parentCtx: tb.ctx,
328+ }, generation)
329+
330+ tb.mu.Lock()
331+ floating := tb.floating.pane
332+ tb.mu.Unlock()
333+ require.NotNil(t, floating)
334+ tb.mu.Lock()
335+ floating.mu.Lock()
336+ floating.publishOwnerLocked(destination, tb, generation)
337+ floating.mu.Unlock()
338+ tb.mu.Unlock()
339+
340+ sourceClient := &attachedClient{captureFrames: map[*pane]capturedPaneRenderState{floating: {}}}
341+ sourceClient.initOverlays()
342+ destinationClient := &attachedClient{captureFrames: map[*pane]capturedPaneRenderState{floating: {}}}
343+ destinationClient.initOverlays()
344+ source.client = sourceClient
345+ destination.client = destinationClient
346+
347+ floating.onExit()
348+ d.sessWg.Wait()
349+
350+ tb.mu.Lock()
351+ require.Equal(t, floatingUninitialized, tb.floating.state)
352+ require.Nil(t, tb.floating.pane)
353+ tb.mu.Unlock()
354+ sourceClient.sendMu.Lock()
355+ _, sourceRetained := sourceClient.captureFrames[floating]
356+ sourceClient.sendMu.Unlock()
357+ destinationClient.sendMu.Lock()
358+ _, destinationRetained := destinationClient.captureFrames[floating]
359+ destinationClient.sendMu.Unlock()
360+ require.True(t, sourceRetained, "exit must not clean up the retired source owner")
361+ require.False(t, destinationRetained, "exit must clean up the current destination owner")
362+}
363+
287 func TestFloatingReapAndTeardownCloseMatchingPaneOnce(t *testing.T) {364 func TestFloatingReapAndTeardownCloseMatchingPaneOnce(t *testing.T) {
288 pty, _ := newBlockingPTY(t)365 pty, _ := newBlockingPTY(t)
289 tb := newFloatingTestTab(t)366 tb := newFloatingTestTab(t)
290 p := newPane(layout.PaneID("floating"), pty, domain.Size{Cols: 10, Rows: 10})367 p := newPane(layout.PaneID("floating"), pty, domain.Size{Cols: 10, Rows: 10})
368+ sess := &session{}
291 tb.mu.Lock()369 tb.mu.Lock()
292 g := tb.beginFloatingWarmLocked(true)370 g := tb.beginFloatingWarmLocked(true)
371+ publishPaneOwner(p, sess, tb, g)
293 require.True(t, tb.installFloatingLocked(p, g))372 require.True(t, tb.installFloatingLocked(p, g))
294 tb.mu.Unlock()373 tb.mu.Unlock()
295 d := newTestDaemon(t, nil, stubClock{})374 d := newTestDaemon(t, nil, stubClock{})
296- d.reapFloating(&session{}, tb, p, g)375+ d.reapInstalledFloating(p)
297 d.teardownFloating(tb, nil)376 d.teardownFloating(tb, nil)
298 pty.AssertNumberOfCalls(t, "Close", 1)377 pty.AssertNumberOfCalls(t, "Close", 1)
299 }378 }
@@ -358,6 +437,7 @@ func TestFloatingEOFRepaintsVisibleSlotOnly(t *testing.T) {
358 }).Once()437 }).Once()
359 floating := newPane(layout.PaneID("floating"), floatingPTY, domain.Size{Cols: 20, Rows: 8})438 floating := newPane(layout.PaneID("floating"), floatingPTY, domain.Size{Cols: 20, Rows: 8})
360 generation := tb.beginFloatingWarmLocked(state == floatingVisible)439 generation := tb.beginFloatingWarmLocked(state == floatingVisible)
440+ publishPaneOwner(floating, sess, tb, generation)
361 require.True(t, tb.installFloatingLocked(floating, generation))441 require.True(t, tb.installFloatingLocked(floating, generation))
362 if state == floatingHidden {442 if state == floatingHidden {
363 require.Equal(t, floatingHidden, tb.floating.state)443 require.Equal(t, floatingHidden, tb.floating.state)
@@ -374,7 +454,7 @@ func TestFloatingEOFRepaintsVisibleSlotOnly(t *testing.T) {
374 454
375 reaped := make(chan struct{})455 reaped := make(chan struct{})
376 floating.onExit = func() {456 floating.onExit = func() {
377- d.reapFloating(sess, tb, floating, generation)457+ d.reapInstalledFloating(floating)
378 close(reaped)458 close(reaped)
379 }459 }
380 d.sessWg.Add(1)460 d.sessWg.Add(1)
A internal/usecase/daemon/move_resize_fences.go +203 -0
@@ -0,0 +1,203 @@
1+package daemon
2+
3+import (
4+ "cmp"
5+ "slices"
6+)
7+
8+// moveResizeFences excludes concurrent layout publishers while a move
9+// revalidates and publishes ownership. It is not an architecture lock. Fences
10+// are always acquired session -> tab -> pane by immutable stable identity and
11+// released in exact reverse order.
12+type moveResizeFences struct {
13+ sessions []*session
14+ tabs []*tab
15+ panes []*pane
16+ held bool
17+}
18+
19+// newMovePaneResizeFences covers both sessions, both affected layouts, and
20+// only the pane whose owner will change. Stable-identity deduplication also
21+// handles moves between tabs in the same session.
22+func newMovePaneResizeFences(source, destination *session, sourceTab, destinationTab *tab, moved *pane) *moveResizeFences {
23+ return newMoveResizeFences(
24+ []*session{source, destination},
25+ []*tab{sourceTab, destinationTab},
26+ []*pane{moved},
27+ )
28+}
29+
30+// newMoveTabResizeFences snapshots every process-bearing pane transferred with
31+// a tab. The acquire callback must revalidate this exact tiled membership and
32+// installed floating slot after all fences are held, because this snapshot is
33+// intentionally taken before waiting on any resize transaction.
34+func newMoveTabResizeFences(source, destination *session, moved *tab) *moveResizeFences {
35+ if moved == nil {
36+ return newMoveResizeFences([]*session{source, destination}, nil, nil)
37+ }
38+ moved.mu.Lock()
39+ panes := make([]*pane, 0, len(moved.panes)+1)
40+ for _, p := range moved.panes {
41+ panes = append(panes, p)
42+ }
43+ if moved.floating.pane != nil && (moved.floating.state == floatingHidden || moved.floating.state == floatingVisible) {
44+ panes = append(panes, moved.floating.pane)
45+ }
46+ moved.mu.Unlock()
47+ return newMoveResizeFences([]*session{source, destination}, []*tab{moved}, panes)
48+}
49+
50+func newMoveResizeFences(sessions []*session, tabs []*tab, panes []*pane) *moveResizeFences {
51+ byID := make(map[string]*session, len(sessions))
52+ for _, sess := range sessions {
53+ if sess == nil {
54+ continue
55+ }
56+ if _, exists := byID[string(sess.id)]; !exists {
57+ byID[string(sess.id)] = sess
58+ }
59+ }
60+ ordered := make([]*session, 0, len(byID))
61+ for _, sess := range byID {
62+ ordered = append(ordered, sess)
63+ }
64+ slices.SortFunc(ordered, func(left, right *session) int {
65+ return cmp.Compare(left.id, right.id)
66+ })
67+
68+ tabsByID := make(map[string]*tab, len(tabs))
69+ for _, tb := range tabs {
70+ if tb == nil {
71+ continue
72+ }
73+ if _, exists := tabsByID[tb.stableID]; !exists {
74+ tabsByID[tb.stableID] = tb
75+ }
76+ }
77+ orderedTabs := make([]*tab, 0, len(tabsByID))
78+ for _, tb := range tabsByID {
79+ orderedTabs = append(orderedTabs, tb)
80+ }
81+ slices.SortFunc(orderedTabs, func(left, right *tab) int {
82+ return cmp.Compare(left.stableID, right.stableID)
83+ })
84+
85+ panesByID := make(map[string]*pane, len(panes))
86+ for _, p := range panes {
87+ if p == nil {
88+ continue
89+ }
90+ if _, exists := panesByID[p.stableID]; !exists {
91+ panesByID[p.stableID] = p
92+ }
93+ }
94+ orderedPanes := make([]*pane, 0, len(panesByID))
95+ for _, p := range panesByID {
96+ orderedPanes = append(orderedPanes, p)
97+ }
98+ slices.SortFunc(orderedPanes, func(left, right *pane) int {
99+ return cmp.Compare(left.stableID, right.stableID)
100+ })
101+ return &moveResizeFences{sessions: ordered, tabs: orderedTabs, panes: orderedPanes}
102+}
103+
104+// acquire waits for every resize fence before invoking revalidateAndPublish.
105+// Callers must enter with no daemon, session, tab, or pane architecture lock
106+// held. The callback is the only architecture-lock window: under the canonical
107+// architecture lock order it must revalidate the sessions, tabs, tiled pane
108+// memberships, and installed floating slot captured before this wait; publish
109+// every changed pane owner; and bump each affected tab's layoutGeneration. A
110+// false result (or panic) releases every acquired fence in reverse order. A true
111+// result retains all fences so no resize can observe half-published ownership;
112+// the caller must Release after architecture locks are dropped and before PTY
113+// I/O, applyTabLayout, or applySessionLayout.
114+func (f *moveResizeFences) acquire(revalidateAndPublish func() bool) (accepted bool) {
115+ if f == nil || f.held {
116+ return false
117+ }
118+ for _, sess := range f.sessions {
119+ sess.layoutApplyMu.Lock()
120+ }
121+ for _, tb := range f.tabs {
122+ tb.layoutApplyMu.Lock()
123+ }
124+ for _, p := range f.panes {
125+ p.resizeMu.Lock()
126+ }
127+ f.held = true
128+ defer func() {
129+ if !accepted {
130+ f.Release()
131+ }
132+ }()
133+ if revalidateAndPublish == nil {
134+ return false
135+ }
136+ accepted = revalidateAndPublish()
137+ return accepted
138+}
139+
140+func (d *Daemon) observeBeforeResizeOwnerPostEffect(effect resizeOwnerPostEffect) {
141+ if d != nil && d.beforeResizeOwnerPostEffect != nil {
142+ d.beforeResizeOwnerPostEffect(effect)
143+ }
144+}
145+
146+// acquireResizeOwnerPostEffectFences acquires and validates the complete
147+// canonical session/tab/pane fence set. It performs no callbacks or external
148+// operations and returns the fences held on success.
149+func acquireResizeOwnerPostEffectFences(members []resizeMember) *moveResizeFences {
150+ if len(members) == 0 {
151+ return nil
152+ }
153+ sessions := make([]*session, 0, len(members))
154+ tabs := make([]*tab, 0, len(members))
155+ panes := make([]*pane, 0, len(members))
156+ for i := range members {
157+ member := &members[i]
158+ sessions = append(sessions, member.session)
159+ tabs = append(tabs, member.tab)
160+ panes = append(panes, member.pane)
161+ }
162+ fences := newMoveResizeFences(sessions, tabs, panes)
163+ if !fences.acquire(func() bool { return resizeMembersOwnerCurrent(members) }) {
164+ return nil
165+ }
166+ return fences
167+}
168+
169+// publishResizeOwnerPostEffect linearizes an in-memory resize effect with pane
170+// ownership publication. The deterministic seam runs before fence acquisition,
171+// then the complete canonical session/tab/pane fence set is acquired and every
172+// immutable owner lease is revalidated. The callback must publish in-memory
173+// state only; timer, PTY, transport, repository, and renderer work belongs
174+// after this method returns.
175+func (d *Daemon) publishResizeOwnerPostEffect(members []resizeMember, effect resizeOwnerPostEffect, publish func()) bool {
176+ if publish == nil {
177+ return false
178+ }
179+ d.observeBeforeResizeOwnerPostEffect(effect)
180+ fences := acquireResizeOwnerPostEffectFences(members)
181+ if fences == nil {
182+ return false
183+ }
184+ publish()
185+ fences.Release()
186+ return true
187+}
188+
189+func (f *moveResizeFences) Release() {
190+ if f == nil || !f.held {
191+ return
192+ }
193+ for i := len(f.panes) - 1; i >= 0; i-- {
194+ f.panes[i].resizeMu.Unlock()
195+ }
196+ for i := len(f.tabs) - 1; i >= 0; i-- {
197+ f.tabs[i].layoutApplyMu.Unlock()
198+ }
199+ for i := len(f.sessions) - 1; i >= 0; i-- {
200+ f.sessions[i].layoutApplyMu.Unlock()
201+ }
202+ f.held = false
203+}
@@ -0,0 +1,203 @@
1+package daemon
2+
3+import (
4+ "cmp"
5+ "slices"
6+)
7+
8+// moveResizeFences excludes concurrent layout publishers while a move
9+// revalidates and publishes ownership. It is not an architecture lock. Fences
10+// are always acquired session -> tab -> pane by immutable stable identity and
11+// released in exact reverse order.
12+type moveResizeFences struct {
13+ sessions []*session
14+ tabs []*tab
15+ panes []*pane
16+ held bool
17+}
18+
19+// newMovePaneResizeFences covers both sessions, both affected layouts, and
20+// only the pane whose owner will change. Stable-identity deduplication also
21+// handles moves between tabs in the same session.
22+func newMovePaneResizeFences(source, destination *session, sourceTab, destinationTab *tab, moved *pane) *moveResizeFences {
23+ return newMoveResizeFences(
24+ []*session{source, destination},
25+ []*tab{sourceTab, destinationTab},
26+ []*pane{moved},
27+ )
28+}
29+
30+// newMoveTabResizeFences snapshots every process-bearing pane transferred with
31+// a tab. The acquire callback must revalidate this exact tiled membership and
32+// installed floating slot after all fences are held, because this snapshot is
33+// intentionally taken before waiting on any resize transaction.
34+func newMoveTabResizeFences(source, destination *session, moved *tab) *moveResizeFences {
35+ if moved == nil {
36+ return newMoveResizeFences([]*session{source, destination}, nil, nil)
37+ }
38+ moved.mu.Lock()
39+ panes := make([]*pane, 0, len(moved.panes)+1)
40+ for _, p := range moved.panes {
41+ panes = append(panes, p)
42+ }
43+ if moved.floating.pane != nil && (moved.floating.state == floatingHidden || moved.floating.state == floatingVisible) {
44+ panes = append(panes, moved.floating.pane)
45+ }
46+ moved.mu.Unlock()
47+ return newMoveResizeFences([]*session{source, destination}, []*tab{moved}, panes)
48+}
49+
50+func newMoveResizeFences(sessions []*session, tabs []*tab, panes []*pane) *moveResizeFences {
51+ byID := make(map[string]*session, len(sessions))
52+ for _, sess := range sessions {
53+ if sess == nil {
54+ continue
55+ }
56+ if _, exists := byID[string(sess.id)]; !exists {
57+ byID[string(sess.id)] = sess
58+ }
59+ }
60+ ordered := make([]*session, 0, len(byID))
61+ for _, sess := range byID {
62+ ordered = append(ordered, sess)
63+ }
64+ slices.SortFunc(ordered, func(left, right *session) int {
65+ return cmp.Compare(left.id, right.id)
66+ })
67+
68+ tabsByID := make(map[string]*tab, len(tabs))
69+ for _, tb := range tabs {
70+ if tb == nil {
71+ continue
72+ }
73+ if _, exists := tabsByID[tb.stableID]; !exists {
74+ tabsByID[tb.stableID] = tb
75+ }
76+ }
77+ orderedTabs := make([]*tab, 0, len(tabsByID))
78+ for _, tb := range tabsByID {
79+ orderedTabs = append(orderedTabs, tb)
80+ }
81+ slices.SortFunc(orderedTabs, func(left, right *tab) int {
82+ return cmp.Compare(left.stableID, right.stableID)
83+ })
84+
85+ panesByID := make(map[string]*pane, len(panes))
86+ for _, p := range panes {
87+ if p == nil {
88+ continue
89+ }
90+ if _, exists := panesByID[p.stableID]; !exists {
91+ panesByID[p.stableID] = p
92+ }
93+ }
94+ orderedPanes := make([]*pane, 0, len(panesByID))
95+ for _, p := range panesByID {
96+ orderedPanes = append(orderedPanes, p)
97+ }
98+ slices.SortFunc(orderedPanes, func(left, right *pane) int {
99+ return cmp.Compare(left.stableID, right.stableID)
100+ })
101+ return &moveResizeFences{sessions: ordered, tabs: orderedTabs, panes: orderedPanes}
102+}
103+
104+// acquire waits for every resize fence before invoking revalidateAndPublish.
105+// Callers must enter with no daemon, session, tab, or pane architecture lock
106+// held. The callback is the only architecture-lock window: under the canonical
107+// architecture lock order it must revalidate the sessions, tabs, tiled pane
108+// memberships, and installed floating slot captured before this wait; publish
109+// every changed pane owner; and bump each affected tab's layoutGeneration. A
110+// false result (or panic) releases every acquired fence in reverse order. A true
111+// result retains all fences so no resize can observe half-published ownership;
112+// the caller must Release after architecture locks are dropped and before PTY
113+// I/O, applyTabLayout, or applySessionLayout.
114+func (f *moveResizeFences) acquire(revalidateAndPublish func() bool) (accepted bool) {
115+ if f == nil || f.held {
116+ return false
117+ }
118+ for _, sess := range f.sessions {
119+ sess.layoutApplyMu.Lock()
120+ }
121+ for _, tb := range f.tabs {
122+ tb.layoutApplyMu.Lock()
123+ }
124+ for _, p := range f.panes {
125+ p.resizeMu.Lock()
126+ }
127+ f.held = true
128+ defer func() {
129+ if !accepted {
130+ f.Release()
131+ }
132+ }()
133+ if revalidateAndPublish == nil {
134+ return false
135+ }
136+ accepted = revalidateAndPublish()
137+ return accepted
138+}
139+
140+func (d *Daemon) observeBeforeResizeOwnerPostEffect(effect resizeOwnerPostEffect) {
141+ if d != nil && d.beforeResizeOwnerPostEffect != nil {
142+ d.beforeResizeOwnerPostEffect(effect)
143+ }
144+}
145+
146+// acquireResizeOwnerPostEffectFences acquires and validates the complete
147+// canonical session/tab/pane fence set. It performs no callbacks or external
148+// operations and returns the fences held on success.
149+func acquireResizeOwnerPostEffectFences(members []resizeMember) *moveResizeFences {
150+ if len(members) == 0 {
151+ return nil
152+ }
153+ sessions := make([]*session, 0, len(members))
154+ tabs := make([]*tab, 0, len(members))
155+ panes := make([]*pane, 0, len(members))
156+ for i := range members {
157+ member := &members[i]
158+ sessions = append(sessions, member.session)
159+ tabs = append(tabs, member.tab)
160+ panes = append(panes, member.pane)
161+ }
162+ fences := newMoveResizeFences(sessions, tabs, panes)
163+ if !fences.acquire(func() bool { return resizeMembersOwnerCurrent(members) }) {
164+ return nil
165+ }
166+ return fences
167+}
168+
169+// publishResizeOwnerPostEffect linearizes an in-memory resize effect with pane
170+// ownership publication. The deterministic seam runs before fence acquisition,
171+// then the complete canonical session/tab/pane fence set is acquired and every
172+// immutable owner lease is revalidated. The callback must publish in-memory
173+// state only; timer, PTY, transport, repository, and renderer work belongs
174+// after this method returns.
175+func (d *Daemon) publishResizeOwnerPostEffect(members []resizeMember, effect resizeOwnerPostEffect, publish func()) bool {
176+ if publish == nil {
177+ return false
178+ }
179+ d.observeBeforeResizeOwnerPostEffect(effect)
180+ fences := acquireResizeOwnerPostEffectFences(members)
181+ if fences == nil {
182+ return false
183+ }
184+ publish()
185+ fences.Release()
186+ return true
187+}
188+
189+func (f *moveResizeFences) Release() {
190+ if f == nil || !f.held {
191+ return
192+ }
193+ for i := len(f.panes) - 1; i >= 0; i-- {
194+ f.panes[i].resizeMu.Unlock()
195+ }
196+ for i := len(f.tabs) - 1; i >= 0; i-- {
197+ f.tabs[i].layoutApplyMu.Unlock()
198+ }
199+ for i := len(f.sessions) - 1; i >= 0; i-- {
200+ f.sessions[i].layoutApplyMu.Unlock()
201+ }
202+ f.held = false
203+}
A internal/usecase/daemon/move_resize_fences_test.go +294 -0
@@ -0,0 +1,294 @@
1+package daemon
2+
3+import (
4+ "sync"
5+ "testing"
6+ "time"
7+
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/vev/internal/usecase/layout"
11+)
12+
13+func requireMutexLocked(t *testing.T, mu *sync.Mutex, message string) {
14+ t.Helper()
15+ require.Eventually(t, func() bool {
16+ if !mu.TryLock() {
17+ return true
18+ }
19+ mu.Unlock()
20+ return false
21+ }, time.Second, time.Millisecond, message)
22+}
23+
24+func TestMoveResizeFencesAcquireSessionsByStableID(t *testing.T) {
25+ first := &session{id: "a-first"}
26+ second := &session{id: "z-second"}
27+ fences := newMoveResizeFences([]*session{second, first}, nil, nil)
28+
29+ first.layoutApplyMu.Lock()
30+ acquired := make(chan bool, 1)
31+ go func() {
32+ acquired <- fences.acquire(func() bool { return true })
33+ }()
34+
35+ require.True(t, second.layoutApplyMu.TryLock(), "acquisition reached the later session before the lowest SessionID")
36+ second.layoutApplyMu.Unlock()
37+ first.layoutApplyMu.Unlock()
38+
39+ require.True(t, awaitTestValue(t, acquired, "ordered resize fences did not acquire"))
40+ fences.Release()
41+ require.True(t, first.layoutApplyMu.TryLock())
42+ first.layoutApplyMu.Unlock()
43+ require.True(t, second.layoutApplyMu.TryLock())
44+ second.layoutApplyMu.Unlock()
45+}
46+
47+func TestMoveResizeFencesAcquireTabsByStableIDAfterSessions(t *testing.T) {
48+ sess := &session{id: "session"}
49+ first := &tab{stableID: "a-first"}
50+ second := &tab{stableID: "z-second"}
51+ fences := newMoveResizeFences([]*session{sess}, []*tab{second, first}, nil)
52+
53+ first.layoutApplyMu.Lock()
54+ acquired := make(chan bool, 1)
55+ go func() {
56+ acquired <- fences.acquire(func() bool { return true })
57+ }()
58+
59+ requireMutexLocked(t, &sess.layoutApplyMu, "session fence was not acquired before waiting on a tab fence")
60+ require.True(t, second.layoutApplyMu.TryLock(), "acquisition reached the later tab before the lowest stable tab ID")
61+ second.layoutApplyMu.Unlock()
62+ select {
63+ case <-acquired:
64+ t.Fatal("resize fences ignored the blocked first tab")
65+ default:
66+ }
67+ first.layoutApplyMu.Unlock()
68+
69+ require.True(t, awaitTestValue(t, acquired, "ordered tab resize fences did not acquire"))
70+ fences.Release()
71+}
72+
73+func TestMoveResizeFencesAcquirePanesByStableIDAfterTabs(t *testing.T) {
74+ sess := &session{id: "session"}
75+ tb := &tab{stableID: "tab"}
76+ first := &pane{stableID: "a-first"}
77+ second := &pane{stableID: "z-second"}
78+ fences := newMoveResizeFences([]*session{sess}, []*tab{tb}, []*pane{second, first})
79+
80+ first.resizeMu.Lock()
81+ acquired := make(chan bool, 1)
82+ go func() {
83+ acquired <- fences.acquire(func() bool { return true })
84+ }()
85+
86+ requireMutexLocked(t, &sess.layoutApplyMu, "session fence was not acquired before pane wait")
87+ requireMutexLocked(t, &tb.layoutApplyMu, "tab fence was not acquired before pane wait")
88+ require.True(t, second.resizeMu.TryLock(), "acquisition reached the later pane before the lowest stable pane ID")
89+ second.resizeMu.Unlock()
90+ select {
91+ case <-acquired:
92+ t.Fatal("resize fences ignored the blocked first pane")
93+ default:
94+ }
95+ first.resizeMu.Unlock()
96+
97+ require.True(t, awaitTestValue(t, acquired, "ordered pane resize fences did not acquire"))
98+ fences.Release()
99+}
100+
101+func TestMoveResizeFencesDeduplicateStableIdentities(t *testing.T) {
102+ sess := &session{id: "session"}
103+ tb := &tab{stableID: "tab"}
104+ p := &pane{stableID: "pane"}
105+
106+ fences := newMoveResizeFences([]*session{sess, sess}, []*tab{tb, tb}, []*pane{p, p})
107+
108+ require.Len(t, fences.sessions, 1)
109+ require.Len(t, fences.tabs, 1)
110+ require.Len(t, fences.panes, 1)
111+ require.True(t, fences.acquire(func() bool { return true }))
112+ fences.Release()
113+}
114+
115+func TestMoveResizeFencesDeduplicateSessionIDWithoutReplacingResolvedOwner(t *testing.T) {
116+ resolved := &session{id: "same"}
117+ collision := &session{id: "same"}
118+
119+ fences := newMoveResizeFences([]*session{resolved, collision}, nil, nil)
120+
121+ require.Len(t, fences.sessions, 1)
122+ require.Same(t, resolved, fences.sessions[0])
123+}
124+
125+func TestMoveResizeFencesDeduplicateTabIDWithoutReplacingResolvedOwner(t *testing.T) {
126+ resolved := &tab{stableID: "same"}
127+ collision := &tab{stableID: "same"}
128+
129+ fences := newMoveResizeFences(nil, []*tab{resolved, collision}, nil)
130+
131+ require.Len(t, fences.tabs, 1)
132+ require.Same(t, resolved, fences.tabs[0])
133+}
134+
135+func TestMoveResizeFencesDeduplicatePaneIDWithoutReplacingResolvedOwner(t *testing.T) {
136+ resolved := &pane{stableID: "same"}
137+ collision := &pane{stableID: "same"}
138+
139+ fences := newMoveResizeFences(nil, nil, []*pane{resolved, collision})
140+
141+ require.Len(t, fences.panes, 1)
142+ require.Same(t, resolved, fences.panes[0])
143+}
144+
145+func TestMoveResizeFencesRejectStaleMembershipAndCleanUp(t *testing.T) {
146+ sess := &session{id: "session"}
147+ tb := &tab{stableID: "tab"}
148+ p := &pane{stableID: "pane"}
149+ fences := newMoveResizeFences([]*session{sess}, []*tab{tb}, []*pane{p})
150+
151+ membershipCurrent := true
152+ p.resizeMu.Lock()
153+ acquired := make(chan bool, 1)
154+ callbackCalled := make(chan struct{})
155+ go func() {
156+ acquired <- fences.acquire(func() bool {
157+ close(callbackCalled)
158+ return membershipCurrent
159+ })
160+ }()
161+ requireMutexLocked(t, &tb.layoutApplyMu, "resize fence acquisition did not reach the blocked pane")
162+ membershipCurrent = false
163+ p.resizeMu.Unlock()
164+
165+ require.False(t, awaitTestValue(t, acquired, "stale resize fence acquisition did not return"))
166+ awaitTestCompletion(t, callbackCalled, "membership was not revalidated after resize fence acquisition")
167+ for _, lock := range []*sync.Mutex{&sess.layoutApplyMu, &tb.layoutApplyMu, &p.resizeMu} {
168+ require.True(t, lock.TryLock(), "rejected acquisition leaked a resize fence")
169+ lock.Unlock()
170+ }
171+}
172+
173+func TestMoveResizeFencesCleanUpWhenPublicationPanics(t *testing.T) {
174+ sess := &session{id: "session"}
175+ tb := &tab{stableID: "tab"}
176+ p := &pane{stableID: "pane"}
177+ fences := newMoveResizeFences([]*session{sess}, []*tab{tb}, []*pane{p})
178+
179+ require.Panics(t, func() {
180+ fences.acquire(func() bool { panic("publication failed") })
181+ })
182+ for _, lock := range []*sync.Mutex{&sess.layoutApplyMu, &tb.layoutApplyMu, &p.resizeMu} {
183+ require.True(t, lock.TryLock(), "panicked publication leaked a partial resize fence set")
184+ lock.Unlock()
185+ }
186+}
187+
188+func TestMoveResizeFencesWaitBeforeTakingArchitectureLocks(t *testing.T) {
189+ d := &Daemon{}
190+ sess := &session{id: "session"}
191+ tb := &tab{stableID: "tab"}
192+ p := &pane{stableID: "pane"}
193+ fences := newMoveResizeFences([]*session{sess}, []*tab{tb}, []*pane{p})
194+
195+ p.resizeMu.Lock()
196+ acquired := make(chan bool, 1)
197+ go func() {
198+ acquired <- fences.acquire(func() bool { return true })
199+ }()
200+ requireMutexLocked(t, &tb.layoutApplyMu, "resize fence acquisition did not reach the blocked pane")
201+
202+ for name, lock := range map[string]*sync.Mutex{
203+ "daemon": &d.mu,
204+ "session": &sess.mu,
205+ "tab": &tb.mu,
206+ } {
207+ require.True(t, lock.TryLock(), "%s architecture lock was held while waiting on pane resize", name)
208+ lock.Unlock()
209+ }
210+ p.resizeMu.Unlock()
211+
212+ require.True(t, awaitTestValue(t, acquired, "resize fences did not acquire after pane resize released"))
213+ fences.Release()
214+}
215+
216+func TestMoveResizeFencesHoldThroughPublicationAndGenerationBumps(t *testing.T) {
217+ source := &session{id: "source"}
218+ destination := &session{id: "destination"}
219+ sourceTab := &tab{stableID: "a-source-tab"}
220+ destinationTab := &tab{stableID: "z-destination-tab"}
221+ p := &pane{stableID: "pane"}
222+ fences := newMoveResizeFences(
223+ []*session{source, destination},
224+ []*tab{sourceTab, destinationTab},
225+ []*pane{p},
226+ )
227+
228+ require.True(t, fences.acquire(func() bool {
229+ // This callback is the move's architecture-lock window: membership is
230+ // revalidated before owner publication and both affected generations bump.
231+ sourceTab.mu.Lock()
232+ destinationTab.mu.Lock()
233+ sourceTab.bumpLayoutGenerationLocked()
234+ destinationTab.bumpLayoutGenerationLocked()
235+ destinationTab.mu.Unlock()
236+ sourceTab.mu.Unlock()
237+ return true
238+ }))
239+ require.Equal(t, uint64(1), sourceTab.layoutGeneration)
240+ require.Equal(t, uint64(1), destinationTab.layoutGeneration)
241+ for _, lock := range []*sync.Mutex{&source.layoutApplyMu, &destination.layoutApplyMu, &sourceTab.layoutApplyMu, &destinationTab.layoutApplyMu, &p.resizeMu} {
242+ require.False(t, lock.TryLock(), "publication returned without retaining every resize fence")
243+ }
244+
245+ // Move callers release here, before applyTabLayout or any other PTY I/O.
246+ fences.Release()
247+ for _, lock := range []*sync.Mutex{&source.layoutApplyMu, &destination.layoutApplyMu, &sourceTab.layoutApplyMu, &destinationTab.layoutApplyMu, &p.resizeMu} {
248+ require.True(t, lock.TryLock(), "publication fence did not release before layout application")
249+ lock.Unlock()
250+ }
251+}
252+
253+func TestMovePaneResizeFencesCoverAffectedOwners(t *testing.T) {
254+ source := &session{id: "source"}
255+ destination := &session{id: "destination"}
256+ sourceTab := &tab{stableID: "source-tab"}
257+ destinationTab := &tab{stableID: "destination-tab"}
258+ moved := &pane{stableID: "moved-pane"}
259+
260+ fences := newMovePaneResizeFences(source, destination, sourceTab, destinationTab, moved)
261+
262+ require.ElementsMatch(t, []*session{source, destination}, fences.sessions)
263+ require.ElementsMatch(t, []*tab{sourceTab, destinationTab}, fences.tabs)
264+ require.Equal(t, []*pane{moved}, fences.panes)
265+}
266+
267+func TestMoveTabResizeFencesCoverEveryContainedPane(t *testing.T) {
268+ for _, tc := range []struct {
269+ name string
270+ state floatingState
271+ }{{name: "hidden", state: floatingHidden}, {name: "visible", state: floatingVisible}} {
272+ t.Run(tc.name, func(t *testing.T) {
273+ source := &session{id: "source"}
274+ destination := &session{id: "destination"}
275+ first := &pane{stableID: "first"}
276+ second := &pane{stableID: "second"}
277+ floating := &pane{stableID: "floating"}
278+ movedTab := &tab{
279+ stableID: "moved-tab",
280+ panes: map[layout.PaneID]*pane{
281+ "pane-1": first,
282+ "pane-2": second,
283+ },
284+ floating: floatingSlot{state: tc.state, pane: floating, generation: 7},
285+ }
286+
287+ fences := newMoveTabResizeFences(source, destination, movedTab)
288+
289+ require.ElementsMatch(t, []*session{source, destination}, fences.sessions)
290+ require.Equal(t, []*tab{movedTab}, fences.tabs)
291+ require.ElementsMatch(t, []*pane{first, second, floating}, fences.panes)
292+ })
293+ }
294+}
@@ -0,0 +1,294 @@
1+package daemon
2+
3+import (
4+ "sync"
5+ "testing"
6+ "time"
7+
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/vev/internal/usecase/layout"
11+)
12+
13+func requireMutexLocked(t *testing.T, mu *sync.Mutex, message string) {
14+ t.Helper()
15+ require.Eventually(t, func() bool {
16+ if !mu.TryLock() {
17+ return true
18+ }
19+ mu.Unlock()
20+ return false
21+ }, time.Second, time.Millisecond, message)
22+}
23+
24+func TestMoveResizeFencesAcquireSessionsByStableID(t *testing.T) {
25+ first := &session{id: "a-first"}
26+ second := &session{id: "z-second"}
27+ fences := newMoveResizeFences([]*session{second, first}, nil, nil)
28+
29+ first.layoutApplyMu.Lock()
30+ acquired := make(chan bool, 1)
31+ go func() {
32+ acquired <- fences.acquire(func() bool { return true })
33+ }()
34+
35+ require.True(t, second.layoutApplyMu.TryLock(), "acquisition reached the later session before the lowest SessionID")
36+ second.layoutApplyMu.Unlock()
37+ first.layoutApplyMu.Unlock()
38+
39+ require.True(t, awaitTestValue(t, acquired, "ordered resize fences did not acquire"))
40+ fences.Release()
41+ require.True(t, first.layoutApplyMu.TryLock())
42+ first.layoutApplyMu.Unlock()
43+ require.True(t, second.layoutApplyMu.TryLock())
44+ second.layoutApplyMu.Unlock()
45+}
46+
47+func TestMoveResizeFencesAcquireTabsByStableIDAfterSessions(t *testing.T) {
48+ sess := &session{id: "session"}
49+ first := &tab{stableID: "a-first"}
50+ second := &tab{stableID: "z-second"}
51+ fences := newMoveResizeFences([]*session{sess}, []*tab{second, first}, nil)
52+
53+ first.layoutApplyMu.Lock()
54+ acquired := make(chan bool, 1)
55+ go func() {
56+ acquired <- fences.acquire(func() bool { return true })
57+ }()
58+
59+ requireMutexLocked(t, &sess.layoutApplyMu, "session fence was not acquired before waiting on a tab fence")
60+ require.True(t, second.layoutApplyMu.TryLock(), "acquisition reached the later tab before the lowest stable tab ID")
61+ second.layoutApplyMu.Unlock()
62+ select {
63+ case <-acquired:
64+ t.Fatal("resize fences ignored the blocked first tab")
65+ default:
66+ }
67+ first.layoutApplyMu.Unlock()
68+
69+ require.True(t, awaitTestValue(t, acquired, "ordered tab resize fences did not acquire"))
70+ fences.Release()
71+}
72+
73+func TestMoveResizeFencesAcquirePanesByStableIDAfterTabs(t *testing.T) {
74+ sess := &session{id: "session"}
75+ tb := &tab{stableID: "tab"}
76+ first := &pane{stableID: "a-first"}
77+ second := &pane{stableID: "z-second"}
78+ fences := newMoveResizeFences([]*session{sess}, []*tab{tb}, []*pane{second, first})
79+
80+ first.resizeMu.Lock()
81+ acquired := make(chan bool, 1)
82+ go func() {
83+ acquired <- fences.acquire(func() bool { return true })
84+ }()
85+
86+ requireMutexLocked(t, &sess.layoutApplyMu, "session fence was not acquired before pane wait")
87+ requireMutexLocked(t, &tb.layoutApplyMu, "tab fence was not acquired before pane wait")
88+ require.True(t, second.resizeMu.TryLock(), "acquisition reached the later pane before the lowest stable pane ID")
89+ second.resizeMu.Unlock()
90+ select {
91+ case <-acquired:
92+ t.Fatal("resize fences ignored the blocked first pane")
93+ default:
94+ }
95+ first.resizeMu.Unlock()
96+
97+ require.True(t, awaitTestValue(t, acquired, "ordered pane resize fences did not acquire"))
98+ fences.Release()
99+}
100+
101+func TestMoveResizeFencesDeduplicateStableIdentities(t *testing.T) {
102+ sess := &session{id: "session"}
103+ tb := &tab{stableID: "tab"}
104+ p := &pane{stableID: "pane"}
105+
106+ fences := newMoveResizeFences([]*session{sess, sess}, []*tab{tb, tb}, []*pane{p, p})
107+
108+ require.Len(t, fences.sessions, 1)
109+ require.Len(t, fences.tabs, 1)
110+ require.Len(t, fences.panes, 1)
111+ require.True(t, fences.acquire(func() bool { return true }))
112+ fences.Release()
113+}
114+
115+func TestMoveResizeFencesDeduplicateSessionIDWithoutReplacingResolvedOwner(t *testing.T) {
116+ resolved := &session{id: "same"}
117+ collision := &session{id: "same"}
118+
119+ fences := newMoveResizeFences([]*session{resolved, collision}, nil, nil)
120+
121+ require.Len(t, fences.sessions, 1)
122+ require.Same(t, resolved, fences.sessions[0])
123+}
124+
125+func TestMoveResizeFencesDeduplicateTabIDWithoutReplacingResolvedOwner(t *testing.T) {
126+ resolved := &tab{stableID: "same"}
127+ collision := &tab{stableID: "same"}
128+
129+ fences := newMoveResizeFences(nil, []*tab{resolved, collision}, nil)
130+
131+ require.Len(t, fences.tabs, 1)
132+ require.Same(t, resolved, fences.tabs[0])
133+}
134+
135+func TestMoveResizeFencesDeduplicatePaneIDWithoutReplacingResolvedOwner(t *testing.T) {
136+ resolved := &pane{stableID: "same"}
137+ collision := &pane{stableID: "same"}
138+
139+ fences := newMoveResizeFences(nil, nil, []*pane{resolved, collision})
140+
141+ require.Len(t, fences.panes, 1)
142+ require.Same(t, resolved, fences.panes[0])
143+}
144+
145+func TestMoveResizeFencesRejectStaleMembershipAndCleanUp(t *testing.T) {
146+ sess := &session{id: "session"}
147+ tb := &tab{stableID: "tab"}
148+ p := &pane{stableID: "pane"}
149+ fences := newMoveResizeFences([]*session{sess}, []*tab{tb}, []*pane{p})
150+
151+ membershipCurrent := true
152+ p.resizeMu.Lock()
153+ acquired := make(chan bool, 1)
154+ callbackCalled := make(chan struct{})
155+ go func() {
156+ acquired <- fences.acquire(func() bool {
157+ close(callbackCalled)
158+ return membershipCurrent
159+ })
160+ }()
161+ requireMutexLocked(t, &tb.layoutApplyMu, "resize fence acquisition did not reach the blocked pane")
162+ membershipCurrent = false
163+ p.resizeMu.Unlock()
164+
165+ require.False(t, awaitTestValue(t, acquired, "stale resize fence acquisition did not return"))
166+ awaitTestCompletion(t, callbackCalled, "membership was not revalidated after resize fence acquisition")
167+ for _, lock := range []*sync.Mutex{&sess.layoutApplyMu, &tb.layoutApplyMu, &p.resizeMu} {
168+ require.True(t, lock.TryLock(), "rejected acquisition leaked a resize fence")
169+ lock.Unlock()
170+ }
171+}
172+
173+func TestMoveResizeFencesCleanUpWhenPublicationPanics(t *testing.T) {
174+ sess := &session{id: "session"}
175+ tb := &tab{stableID: "tab"}
176+ p := &pane{stableID: "pane"}
177+ fences := newMoveResizeFences([]*session{sess}, []*tab{tb}, []*pane{p})
178+
179+ require.Panics(t, func() {
180+ fences.acquire(func() bool { panic("publication failed") })
181+ })
182+ for _, lock := range []*sync.Mutex{&sess.layoutApplyMu, &tb.layoutApplyMu, &p.resizeMu} {
183+ require.True(t, lock.TryLock(), "panicked publication leaked a partial resize fence set")
184+ lock.Unlock()
185+ }
186+}
187+
188+func TestMoveResizeFencesWaitBeforeTakingArchitectureLocks(t *testing.T) {
189+ d := &Daemon{}
190+ sess := &session{id: "session"}
191+ tb := &tab{stableID: "tab"}
192+ p := &pane{stableID: "pane"}
193+ fences := newMoveResizeFences([]*session{sess}, []*tab{tb}, []*pane{p})
194+
195+ p.resizeMu.Lock()
196+ acquired := make(chan bool, 1)
197+ go func() {
198+ acquired <- fences.acquire(func() bool { return true })
199+ }()
200+ requireMutexLocked(t, &tb.layoutApplyMu, "resize fence acquisition did not reach the blocked pane")
201+
202+ for name, lock := range map[string]*sync.Mutex{
203+ "daemon": &d.mu,
204+ "session": &sess.mu,
205+ "tab": &tb.mu,
206+ } {
207+ require.True(t, lock.TryLock(), "%s architecture lock was held while waiting on pane resize", name)
208+ lock.Unlock()
209+ }
210+ p.resizeMu.Unlock()
211+
212+ require.True(t, awaitTestValue(t, acquired, "resize fences did not acquire after pane resize released"))
213+ fences.Release()
214+}
215+
216+func TestMoveResizeFencesHoldThroughPublicationAndGenerationBumps(t *testing.T) {
217+ source := &session{id: "source"}
218+ destination := &session{id: "destination"}
219+ sourceTab := &tab{stableID: "a-source-tab"}
220+ destinationTab := &tab{stableID: "z-destination-tab"}
221+ p := &pane{stableID: "pane"}
222+ fences := newMoveResizeFences(
223+ []*session{source, destination},
224+ []*tab{sourceTab, destinationTab},
225+ []*pane{p},
226+ )
227+
228+ require.True(t, fences.acquire(func() bool {
229+ // This callback is the move's architecture-lock window: membership is
230+ // revalidated before owner publication and both affected generations bump.
231+ sourceTab.mu.Lock()
232+ destinationTab.mu.Lock()
233+ sourceTab.bumpLayoutGenerationLocked()
234+ destinationTab.bumpLayoutGenerationLocked()
235+ destinationTab.mu.Unlock()
236+ sourceTab.mu.Unlock()
237+ return true
238+ }))
239+ require.Equal(t, uint64(1), sourceTab.layoutGeneration)
240+ require.Equal(t, uint64(1), destinationTab.layoutGeneration)
241+ for _, lock := range []*sync.Mutex{&source.layoutApplyMu, &destination.layoutApplyMu, &sourceTab.layoutApplyMu, &destinationTab.layoutApplyMu, &p.resizeMu} {
242+ require.False(t, lock.TryLock(), "publication returned without retaining every resize fence")
243+ }
244+
245+ // Move callers release here, before applyTabLayout or any other PTY I/O.
246+ fences.Release()
247+ for _, lock := range []*sync.Mutex{&source.layoutApplyMu, &destination.layoutApplyMu, &sourceTab.layoutApplyMu, &destinationTab.layoutApplyMu, &p.resizeMu} {
248+ require.True(t, lock.TryLock(), "publication fence did not release before layout application")
249+ lock.Unlock()
250+ }
251+}
252+
253+func TestMovePaneResizeFencesCoverAffectedOwners(t *testing.T) {
254+ source := &session{id: "source"}
255+ destination := &session{id: "destination"}
256+ sourceTab := &tab{stableID: "source-tab"}
257+ destinationTab := &tab{stableID: "destination-tab"}
258+ moved := &pane{stableID: "moved-pane"}
259+
260+ fences := newMovePaneResizeFences(source, destination, sourceTab, destinationTab, moved)
261+
262+ require.ElementsMatch(t, []*session{source, destination}, fences.sessions)
263+ require.ElementsMatch(t, []*tab{sourceTab, destinationTab}, fences.tabs)
264+ require.Equal(t, []*pane{moved}, fences.panes)
265+}
266+
267+func TestMoveTabResizeFencesCoverEveryContainedPane(t *testing.T) {
268+ for _, tc := range []struct {
269+ name string
270+ state floatingState
271+ }{{name: "hidden", state: floatingHidden}, {name: "visible", state: floatingVisible}} {
272+ t.Run(tc.name, func(t *testing.T) {
273+ source := &session{id: "source"}
274+ destination := &session{id: "destination"}
275+ first := &pane{stableID: "first"}
276+ second := &pane{stableID: "second"}
277+ floating := &pane{stableID: "floating"}
278+ movedTab := &tab{
279+ stableID: "moved-tab",
280+ panes: map[layout.PaneID]*pane{
281+ "pane-1": first,
282+ "pane-2": second,
283+ },
284+ floating: floatingSlot{state: tc.state, pane: floating, generation: 7},
285+ }
286+
287+ fences := newMoveTabResizeFences(source, destination, movedTab)
288+
289+ require.ElementsMatch(t, []*session{source, destination}, fences.sessions)
290+ require.Equal(t, []*tab{movedTab}, fences.tabs)
291+ require.ElementsMatch(t, []*pane{first, second, floating}, fences.panes)
292+ })
293+ }
294+}
M internal/usecase/daemon/render_coordinator.go +71 -59
@@ -132,35 +132,46 @@ func (c *renderCoordinator) invalidateForAttachmentAtResizeEpoch(source *attache
132132 return c.invalidateForLeaseAtResizeEpoch(source, nil, epoch, inv)
133133 }
134134
135-// invalidateForLeaseAtResizeEpoch is the attachment-callback variant: it
136-// rejects a same-object replacement whose source pointer still matches but
137-// whose lifecycle lease has been revoked.
138-func (c *renderCoordinator) invalidateForLeaseAtResizeEpoch(source *attachedClient, lease *attachmentLease, epoch uint64, inv renderInvalidation) bool {
135+// renderInvalidationReservation is the in-memory half of an invalidation.
136+// finish performs observer, timer, and callback work after ownership fences are
137+// released.
138+type renderInvalidationReservation struct {
139+ coordinator *renderCoordinator
140+ invalidation renderInvalidation
141+ onInvalidate func(renderInvalidation)
142+ queueStart bool
143+ queueCorrelation ports.RuntimeCorrelation
144+ observer ports.RuntimeObserver
145+ old *timerToken
146+ arm bool
147+ generation uint64
148+ delay time.Duration
149+ clock ports.Clock
150+}
151+
152+// reserveInvalidationForLeaseAtResizeEpoch publishes only coordinator memory.
153+// It is safe inside a short pane-owner fence and performs no timer or observer
154+// calls.
155+func (c *renderCoordinator) reserveInvalidationForLeaseAtResizeEpoch(source *attachedClient, lease *attachmentLease, epoch uint64, inv renderInvalidation) (*renderInvalidationReservation, bool) {
139156 c.mu.Lock()
140- // An unbound coordinator is the manual/headless harness state; there is no
141- // published production attachment to protect there. A parked target may
142- // still publish its own PTY/session mutations to picker observers, but no
143- // attachment-owned callback may revive that target's render path.
157+ defer c.mu.Unlock()
144158 detachedPreviewOnly := c.primaryDetachedLocked() && source == nil && (c.previewWake != nil || len(c.previewWakes) != 0)
145159 if c.torndown || (lease != nil && (!c.leaseCurrentLocked(lease, true) || lease.attachment != source)) ||
146160 (c.primaryDetachedLocked() && !detachedPreviewOnly) || (source != nil && c.lease != nil && c.lease.attachment != source) ||
147161 (epoch != 0 && (c.resize.epoch != epoch || c.resize.source != source || (lease != nil && c.resize.lease != lease))) {
148- c.mu.Unlock()
149- return false
162+ return nil, false
150163 }
151- onInvalidate := c.opts.onInvalidate
164+ reservation := &renderInvalidationReservation{coordinator: c, invalidation: inv, onInvalidate: c.opts.onInvalidate}
152165 c.metrics.invalidations.Add(1)
153166 wasPending, wasUrgent, wasPreviewPending := c.pending, c.pendingUrgent, c.pendingPreview
154- queueStart := false
155- var queueCorrelation ports.RuntimeCorrelation
156167 if !wasPending {
157168 c.ackDeferred = false
158169 c.deadlineDue = false
159170 if c.opts.observer != nil {
160171 c.queueMarked = true
161172 c.queueCorrelation = ports.NewRuntimeCorrelation()
162- queueCorrelation = c.queueCorrelation
163- queueStart = true
173+ reservation.queueCorrelation = c.queueCorrelation
174+ reservation.queueStart = true
164175 }
165176 }
166177 c.pending = true
@@ -168,43 +179,43 @@ func (c *renderCoordinator) invalidateForLeaseAtResizeEpoch(source *attachedClie
168179 c.pendingUrgent = c.pendingUrgent || inv.class == invalidateUrgent
169180 c.pendingPreview = c.pendingPreview || c.previewWake != nil || len(c.previewWakes) != 0
170181 c.coalesced++
171- // A deadline may have expired while synchronized output still gated the
172- // pending work. Completion republishes urgently and must reserve a fresh
173- // deadline rather than leaving that already-fired timer as the only arm.
174- arm := !wasPending || (!wasUrgent && c.pendingUrgent) || (!wasPreviewPending && c.pendingPreview) || c.deadlineDue
175- var old *timerToken
176- if arm {
177- _, old = c.normalLane.replaceLocked()
182+ reservation.arm = !wasPending || (!wasUrgent && c.pendingUrgent) || (!wasPreviewPending && c.pendingPreview) || c.deadlineDue
183+ if reservation.arm {
184+ _, reservation.old = c.normalLane.replaceLocked()
178185 }
179- gen := c.normalLane.generation
180- delay := minOutputRenderDeadline + time.Duration(c.outputPressure)*time.Millisecond
186+ reservation.generation = c.normalLane.generation
187+ reservation.delay = minOutputRenderDeadline + time.Duration(c.outputPressure)*time.Millisecond
181188 if c.pendingUrgent {
182- delay = urgentRenderDeadline
189+ reservation.delay = urgentRenderDeadline
183190 }
184- clock := c.opts.clock
185- c.armed = c.armed || arm
186- observer := c.opts.observer
187- c.mu.Unlock()
188- if queueStart && observer != nil {
189- observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", queueCorrelation, ports.RuntimeQueueEnqueued, 0, true))
191+ reservation.clock = c.opts.clock
192+ reservation.observer = c.opts.observer
193+ c.armed = c.armed || reservation.arm
194+ return reservation, true
195+}
196+
197+func (r *renderInvalidationReservation) finish() {
198+ if r == nil || r.coordinator == nil {
199+ return
190200 }
191- stopDetachedTimer(old)
192- if !arm || clock == nil {
193- if onInvalidate != nil {
194- onInvalidate(inv)
201+ c := r.coordinator
202+ if r.queueStart && r.observer != nil {
203+ r.observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", r.queueCorrelation, ports.RuntimeQueueEnqueued, 0, true))
204+ }
205+ stopDetachedTimer(r.old)
206+ if !r.arm || r.clock == nil {
207+ if r.onInvalidate != nil {
208+ r.onInvalidate(r.invalidation)
195209 }
196- return true
210+ return
197211 }
198-
199- // NewTimer and C may re-enter the coordinator. Publish only if this
200- // generation is still the reserved deadline after those external calls.
201- timer := clock.NewTimer(delay)
212+ timer := r.clock.NewTimer(r.delay)
202213 timerC := timer.C()
203214 c.mu.Lock()
204- valid := !c.torndown && c.pending && c.normalLane.generation == gen
215+ valid := !c.torndown && c.pending && c.normalLane.generation == r.generation
205216 var token *timerToken
206217 if valid && timerC != nil {
207- token = c.normalLane.publishLocked(gen, timer)
218+ token = c.normalLane.publishLocked(r.generation, timer)
208219 valid = token != nil
209220 if valid {
210221 c.supervisor.startLocked(token, timerC, func() {
@@ -216,26 +227,27 @@ func (c *renderCoordinator) invalidateForLeaseAtResizeEpoch(source *attachedClie
216227 c.mu.Unlock()
217228 if !valid {
218229 timer.Stop()
219- if onInvalidate != nil {
220- onInvalidate(inv)
221- }
222- return true
223- }
224- if timerC == nil {
230+ } else if timerC == nil {
225231 timer.Stop()
226- c.fire(gen, false, true)
227- if onInvalidate != nil {
228- onInvalidate(inv)
229- }
230- return true
232+ c.fire(r.generation, false, true)
233+ }
234+ if r.onInvalidate != nil {
235+ r.onInvalidate(r.invalidation)
236+ }
237+ if valid && timerC != nil {
238+ runtime.Gosched()
231239 }
232- // Test-visible observation follows deadline publication, so a producer
233- // callback cannot observe a successful invalidation before its timer owns
234- // a worker (and race tests cannot finish while a mock expectation remains).
235- if onInvalidate != nil {
236- onInvalidate(inv)
240+}
241+
242+// invalidateForLeaseAtResizeEpoch is the attachment-callback variant: it
243+// rejects a same-object replacement whose source pointer still matches but
244+// whose lifecycle lease has been revoked.
245+func (c *renderCoordinator) invalidateForLeaseAtResizeEpoch(source *attachedClient, lease *attachmentLease, epoch uint64, inv renderInvalidation) bool {
246+ reservation, ok := c.reserveInvalidationForLeaseAtResizeEpoch(source, lease, epoch, inv)
247+ if !ok {
248+ return false
237249 }
238- runtime.Gosched()
250+ reservation.finish()
239251 return true
240252 }
241253
@@ -132,35 +132,46 @@ func (c *renderCoordinator) invalidateForAttachmentAtResizeEpoch(source *attache
132 return c.invalidateForLeaseAtResizeEpoch(source, nil, epoch, inv)132 return c.invalidateForLeaseAtResizeEpoch(source, nil, epoch, inv)
133 }133 }
134 134
135-// invalidateForLeaseAtResizeEpoch is the attachment-callback variant: it135+// renderInvalidationReservation is the in-memory half of an invalidation.
136-// rejects a same-object replacement whose source pointer still matches but136+// finish performs observer, timer, and callback work after ownership fences are
137-// whose lifecycle lease has been revoked.137+// released.
138-func (c *renderCoordinator) invalidateForLeaseAtResizeEpoch(source *attachedClient, lease *attachmentLease, epoch uint64, inv renderInvalidation) bool {138+type renderInvalidationReservation struct {
139+ coordinator *renderCoordinator
140+ invalidation renderInvalidation
141+ onInvalidate func(renderInvalidation)
142+ queueStart bool
143+ queueCorrelation ports.RuntimeCorrelation
144+ observer ports.RuntimeObserver
145+ old *timerToken
146+ arm bool
147+ generation uint64
148+ delay time.Duration
149+ clock ports.Clock
150+}
151+
152+// reserveInvalidationForLeaseAtResizeEpoch publishes only coordinator memory.
153+// It is safe inside a short pane-owner fence and performs no timer or observer
154+// calls.
155+func (c *renderCoordinator) reserveInvalidationForLeaseAtResizeEpoch(source *attachedClient, lease *attachmentLease, epoch uint64, inv renderInvalidation) (*renderInvalidationReservation, bool) {
139 c.mu.Lock()156 c.mu.Lock()
140- // An unbound coordinator is the manual/headless harness state; there is no157+ defer c.mu.Unlock()
141- // published production attachment to protect there. A parked target may
142- // still publish its own PTY/session mutations to picker observers, but no
143- // attachment-owned callback may revive that target's render path.
144 detachedPreviewOnly := c.primaryDetachedLocked() && source == nil && (c.previewWake != nil || len(c.previewWakes) != 0)158 detachedPreviewOnly := c.primaryDetachedLocked() && source == nil && (c.previewWake != nil || len(c.previewWakes) != 0)
145 if c.torndown || (lease != nil && (!c.leaseCurrentLocked(lease, true) || lease.attachment != source)) ||159 if c.torndown || (lease != nil && (!c.leaseCurrentLocked(lease, true) || lease.attachment != source)) ||
146 (c.primaryDetachedLocked() && !detachedPreviewOnly) || (source != nil && c.lease != nil && c.lease.attachment != source) ||160 (c.primaryDetachedLocked() && !detachedPreviewOnly) || (source != nil && c.lease != nil && c.lease.attachment != source) ||
147 (epoch != 0 && (c.resize.epoch != epoch || c.resize.source != source || (lease != nil && c.resize.lease != lease))) {161 (epoch != 0 && (c.resize.epoch != epoch || c.resize.source != source || (lease != nil && c.resize.lease != lease))) {
148- c.mu.Unlock()162+ return nil, false
149- return false
150 }163 }
151- onInvalidate := c.opts.onInvalidate164+ reservation := &renderInvalidationReservation{coordinator: c, invalidation: inv, onInvalidate: c.opts.onInvalidate}
152 c.metrics.invalidations.Add(1)165 c.metrics.invalidations.Add(1)
153 wasPending, wasUrgent, wasPreviewPending := c.pending, c.pendingUrgent, c.pendingPreview166 wasPending, wasUrgent, wasPreviewPending := c.pending, c.pendingUrgent, c.pendingPreview
154- queueStart := false
155- var queueCorrelation ports.RuntimeCorrelation
156 if !wasPending {167 if !wasPending {
157 c.ackDeferred = false168 c.ackDeferred = false
158 c.deadlineDue = false169 c.deadlineDue = false
159 if c.opts.observer != nil {170 if c.opts.observer != nil {
160 c.queueMarked = true171 c.queueMarked = true
161 c.queueCorrelation = ports.NewRuntimeCorrelation()172 c.queueCorrelation = ports.NewRuntimeCorrelation()
162- queueCorrelation = c.queueCorrelation173+ reservation.queueCorrelation = c.queueCorrelation
163- queueStart = true174+ reservation.queueStart = true
164 }175 }
165 }176 }
166 c.pending = true177 c.pending = true
@@ -168,43 +179,43 @@ func (c *renderCoordinator) invalidateForLeaseAtResizeEpoch(source *attachedClie
168 c.pendingUrgent = c.pendingUrgent || inv.class == invalidateUrgent179 c.pendingUrgent = c.pendingUrgent || inv.class == invalidateUrgent
169 c.pendingPreview = c.pendingPreview || c.previewWake != nil || len(c.previewWakes) != 0180 c.pendingPreview = c.pendingPreview || c.previewWake != nil || len(c.previewWakes) != 0
170 c.coalesced++181 c.coalesced++
171- // A deadline may have expired while synchronized output still gated the182+ reservation.arm = !wasPending || (!wasUrgent && c.pendingUrgent) || (!wasPreviewPending && c.pendingPreview) || c.deadlineDue
172- // pending work. Completion republishes urgently and must reserve a fresh183+ if reservation.arm {
173- // deadline rather than leaving that already-fired timer as the only arm.184+ _, reservation.old = c.normalLane.replaceLocked()
174- arm := !wasPending || (!wasUrgent && c.pendingUrgent) || (!wasPreviewPending && c.pendingPreview) || c.deadlineDue
175- var old *timerToken
176- if arm {
177- _, old = c.normalLane.replaceLocked()
178 }185 }
179- gen := c.normalLane.generation186+ reservation.generation = c.normalLane.generation
180- delay := minOutputRenderDeadline + time.Duration(c.outputPressure)*time.Millisecond187+ reservation.delay = minOutputRenderDeadline + time.Duration(c.outputPressure)*time.Millisecond
181 if c.pendingUrgent {188 if c.pendingUrgent {
182- delay = urgentRenderDeadline189+ reservation.delay = urgentRenderDeadline
183 }190 }
184- clock := c.opts.clock191+ reservation.clock = c.opts.clock
185- c.armed = c.armed || arm192+ reservation.observer = c.opts.observer
186- observer := c.opts.observer193+ c.armed = c.armed || reservation.arm
187- c.mu.Unlock()194+ return reservation, true
188- if queueStart && observer != nil {195+}
189- observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", queueCorrelation, ports.RuntimeQueueEnqueued, 0, true))196+
197+func (r *renderInvalidationReservation) finish() {
198+ if r == nil || r.coordinator == nil {
199+ return
190 }200 }
191- stopDetachedTimer(old)201+ c := r.coordinator
192- if !arm || clock == nil {202+ if r.queueStart && r.observer != nil {
193- if onInvalidate != nil {203+ r.observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", r.queueCorrelation, ports.RuntimeQueueEnqueued, 0, true))
194- onInvalidate(inv)204+ }
205+ stopDetachedTimer(r.old)
206+ if !r.arm || r.clock == nil {
207+ if r.onInvalidate != nil {
208+ r.onInvalidate(r.invalidation)
195 }209 }
196- return true210+ return
197 }211 }
198-212+ timer := r.clock.NewTimer(r.delay)
199- // NewTimer and C may re-enter the coordinator. Publish only if this
200- // generation is still the reserved deadline after those external calls.
201- timer := clock.NewTimer(delay)
202 timerC := timer.C()213 timerC := timer.C()
203 c.mu.Lock()214 c.mu.Lock()
204- valid := !c.torndown && c.pending && c.normalLane.generation == gen215+ valid := !c.torndown && c.pending && c.normalLane.generation == r.generation
205 var token *timerToken216 var token *timerToken
206 if valid && timerC != nil {217 if valid && timerC != nil {
207- token = c.normalLane.publishLocked(gen, timer)218+ token = c.normalLane.publishLocked(r.generation, timer)
208 valid = token != nil219 valid = token != nil
209 if valid {220 if valid {
210 c.supervisor.startLocked(token, timerC, func() {221 c.supervisor.startLocked(token, timerC, func() {
@@ -216,26 +227,27 @@ func (c *renderCoordinator) invalidateForLeaseAtResizeEpoch(source *attachedClie
216 c.mu.Unlock()227 c.mu.Unlock()
217 if !valid {228 if !valid {
218 timer.Stop()229 timer.Stop()
219- if onInvalidate != nil {230+ } else if timerC == nil {
220- onInvalidate(inv)
221- }
222- return true
223- }
224- if timerC == nil {
225 timer.Stop()231 timer.Stop()
226- c.fire(gen, false, true)232+ c.fire(r.generation, false, true)
227- if onInvalidate != nil {233+ }
228- onInvalidate(inv)234+ if r.onInvalidate != nil {
229- }235+ r.onInvalidate(r.invalidation)
230- return true236+ }
237+ if valid && timerC != nil {
238+ runtime.Gosched()
231 }239 }
232- // Test-visible observation follows deadline publication, so a producer240+}
233- // callback cannot observe a successful invalidation before its timer owns241+
234- // a worker (and race tests cannot finish while a mock expectation remains).242+// invalidateForLeaseAtResizeEpoch is the attachment-callback variant: it
235- if onInvalidate != nil {243+// rejects a same-object replacement whose source pointer still matches but
236- onInvalidate(inv)244+// whose lifecycle lease has been revoked.
245+func (c *renderCoordinator) invalidateForLeaseAtResizeEpoch(source *attachedClient, lease *attachmentLease, epoch uint64, inv renderInvalidation) bool {
246+ reservation, ok := c.reserveInvalidationForLeaseAtResizeEpoch(source, lease, epoch, inv)
247+ if !ok {
248+ return false
237 }249 }
238- runtime.Gosched()250+ reservation.finish()
239 return true251 return true
240 }252 }
241 253
M internal/usecase/daemon/render_resize_deadline.go +43 -13
@@ -1,6 +1,9 @@
11 package daemon
22
3-import "github.com/bnema/vev/internal/domain"
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/ports"
6+)
47
58 // Resize deadlines and retries retain coordinator-owned attachment, epoch, and
69 // token validation at every callback effect boundary.
@@ -90,40 +93,59 @@ func (c *renderCoordinator) resizeCurrentForLease(epoch uint64, source *attached
9093 return true
9194 }
9295
93-func (c *renderCoordinator) scheduleResizeRetryForLease(epoch uint64, source *attachedClient, lease *attachmentLease, run func()) {
96+type resizeRetryReservation struct {
97+ coordinator *renderCoordinator
98+ epoch uint64
99+ source *attachedClient
100+ lease *attachmentLease
101+ run func()
102+ generation uint64
103+ old *timerToken
104+ clock ports.Clock
105+}
106+
107+// reserveResizeRetryForLease publishes the retry-lane generation only. Timer
108+// calls are deferred to finish so owner fences protect no external operation.
109+func (c *renderCoordinator) reserveResizeRetryForLease(epoch uint64, source *attachedClient, lease *attachmentLease, run func()) (*resizeRetryReservation, bool) {
94110 c.mu.Lock()
111+ defer c.mu.Unlock()
95112 if !c.leaseCurrentLocked(lease, false) || lease.attachment != source || c.resize.lease != lease || c.resize.source != source || c.resize.epoch != epoch || c.resize.committed != epoch {
96- c.mu.Unlock()
97- return
113+ return nil, false
98114 }
99115 gen, old := c.retryLane.replaceLocked()
100- clock := c.opts.clock
101- c.mu.Unlock()
102- stopDetachedTimer(old)
103- if clock == nil {
116+ return &resizeRetryReservation{coordinator: c, epoch: epoch, source: source, lease: lease, run: run, generation: gen, old: old, clock: c.opts.clock}, true
117+}
118+
119+func (r *resizeRetryReservation) finish() {
120+ if r == nil || r.coordinator == nil {
104121 return
105122 }
106- timer := clock.NewTimer(minOutputRenderDeadline)
123+ c := r.coordinator
124+ stopDetachedTimer(r.old)
125+ if r.clock == nil {
126+ return
127+ }
128+ timer := r.clock.NewTimer(minOutputRenderDeadline)
107129 timerC := timer.C()
108130 if timerC == nil {
109131 timer.Stop()
110132 return
111133 }
112134 c.mu.Lock()
113- valid := !c.torndown && c.retryLane.generation == gen && c.leaseCurrentLocked(lease, false) && c.resize.lease == lease && c.resize.epoch == epoch && c.resize.committed == epoch && c.resize.source == source
135+ valid := !c.torndown && c.retryLane.generation == r.generation && c.leaseCurrentLocked(r.lease, false) && c.resize.lease == r.lease && c.resize.epoch == r.epoch && c.resize.committed == r.epoch && c.resize.source == r.source
114136 if valid {
115- token := c.retryLane.publishLocked(gen, timer)
137+ token := c.retryLane.publishLocked(r.generation, timer)
116138 valid = token != nil
117139 if valid {
118140 c.supervisor.startLocked(token, timerC, func() {
119141 c.mu.Lock()
120- valid := !c.torndown && c.retryLane.token == token && c.retryLane.generation == token.generation && c.leaseCurrentLocked(lease, false) && c.resize.lease == lease && c.resize.epoch == epoch && c.resize.committed == epoch && c.resize.source == source
142+ valid := !c.torndown && c.retryLane.token == token && c.retryLane.generation == token.generation && c.leaseCurrentLocked(r.lease, false) && c.resize.lease == r.lease && c.resize.epoch == r.epoch && c.resize.committed == r.epoch && c.resize.source == r.source
121143 if valid {
122144 c.retryLane.clearLocked(token)
123145 }
124146 c.mu.Unlock()
125147 if valid {
126- run()
148+ r.run()
127149 }
128150 })
129151 }
@@ -134,6 +156,14 @@ func (c *renderCoordinator) scheduleResizeRetryForLease(epoch uint64, source *at
134156 }
135157 }
136158
159+func (c *renderCoordinator) scheduleResizeRetryForLease(epoch uint64, source *attachedClient, lease *attachmentLease, run func()) {
160+ reservation, ok := c.reserveResizeRetryForLease(epoch, source, lease, run)
161+ if !ok {
162+ return
163+ }
164+ reservation.finish()
165+}
166+
137167 func (c *renderCoordinator) retryCurrentForLease(epoch uint64, source *attachedClient, lease *attachmentLease) bool {
138168 c.mu.Lock()
139169 defer c.mu.Unlock()
@@ -1,6 +1,9 @@
1 package daemon1 package daemon
2 2
3-import "github.com/bnema/vev/internal/domain"3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/ports"
6+)
4 7
5 // Resize deadlines and retries retain coordinator-owned attachment, epoch, and8 // Resize deadlines and retries retain coordinator-owned attachment, epoch, and
6 // token validation at every callback effect boundary.9 // token validation at every callback effect boundary.
@@ -90,40 +93,59 @@ func (c *renderCoordinator) resizeCurrentForLease(epoch uint64, source *attached
90 return true93 return true
91 }94 }
92 95
93-func (c *renderCoordinator) scheduleResizeRetryForLease(epoch uint64, source *attachedClient, lease *attachmentLease, run func()) {96+type resizeRetryReservation struct {
97+ coordinator *renderCoordinator
98+ epoch uint64
99+ source *attachedClient
100+ lease *attachmentLease
101+ run func()
102+ generation uint64
103+ old *timerToken
104+ clock ports.Clock
105+}
106+
107+// reserveResizeRetryForLease publishes the retry-lane generation only. Timer
108+// calls are deferred to finish so owner fences protect no external operation.
109+func (c *renderCoordinator) reserveResizeRetryForLease(epoch uint64, source *attachedClient, lease *attachmentLease, run func()) (*resizeRetryReservation, bool) {
94 c.mu.Lock()110 c.mu.Lock()
111+ defer c.mu.Unlock()
95 if !c.leaseCurrentLocked(lease, false) || lease.attachment != source || c.resize.lease != lease || c.resize.source != source || c.resize.epoch != epoch || c.resize.committed != epoch {112 if !c.leaseCurrentLocked(lease, false) || lease.attachment != source || c.resize.lease != lease || c.resize.source != source || c.resize.epoch != epoch || c.resize.committed != epoch {
96- c.mu.Unlock()113+ return nil, false
97- return
98 }114 }
99 gen, old := c.retryLane.replaceLocked()115 gen, old := c.retryLane.replaceLocked()
100- clock := c.opts.clock116+ return &resizeRetryReservation{coordinator: c, epoch: epoch, source: source, lease: lease, run: run, generation: gen, old: old, clock: c.opts.clock}, true
101- c.mu.Unlock()117+}
102- stopDetachedTimer(old)118+
103- if clock == nil {119+func (r *resizeRetryReservation) finish() {
120+ if r == nil || r.coordinator == nil {
104 return121 return
105 }122 }
106- timer := clock.NewTimer(minOutputRenderDeadline)123+ c := r.coordinator
124+ stopDetachedTimer(r.old)
125+ if r.clock == nil {
126+ return
127+ }
128+ timer := r.clock.NewTimer(minOutputRenderDeadline)
107 timerC := timer.C()129 timerC := timer.C()
108 if timerC == nil {130 if timerC == nil {
109 timer.Stop()131 timer.Stop()
110 return132 return
111 }133 }
112 c.mu.Lock()134 c.mu.Lock()
113- valid := !c.torndown && c.retryLane.generation == gen && c.leaseCurrentLocked(lease, false) && c.resize.lease == lease && c.resize.epoch == epoch && c.resize.committed == epoch && c.resize.source == source135+ valid := !c.torndown && c.retryLane.generation == r.generation && c.leaseCurrentLocked(r.lease, false) && c.resize.lease == r.lease && c.resize.epoch == r.epoch && c.resize.committed == r.epoch && c.resize.source == r.source
114 if valid {136 if valid {
115- token := c.retryLane.publishLocked(gen, timer)137+ token := c.retryLane.publishLocked(r.generation, timer)
116 valid = token != nil138 valid = token != nil
117 if valid {139 if valid {
118 c.supervisor.startLocked(token, timerC, func() {140 c.supervisor.startLocked(token, timerC, func() {
119 c.mu.Lock()141 c.mu.Lock()
120- valid := !c.torndown && c.retryLane.token == token && c.retryLane.generation == token.generation && c.leaseCurrentLocked(lease, false) && c.resize.lease == lease && c.resize.epoch == epoch && c.resize.committed == epoch && c.resize.source == source142+ valid := !c.torndown && c.retryLane.token == token && c.retryLane.generation == token.generation && c.leaseCurrentLocked(r.lease, false) && c.resize.lease == r.lease && c.resize.epoch == r.epoch && c.resize.committed == r.epoch && c.resize.source == r.source
121 if valid {143 if valid {
122 c.retryLane.clearLocked(token)144 c.retryLane.clearLocked(token)
123 }145 }
124 c.mu.Unlock()146 c.mu.Unlock()
125 if valid {147 if valid {
126- run()148+ r.run()
127 }149 }
128 })150 })
129 }151 }
@@ -134,6 +156,14 @@ func (c *renderCoordinator) scheduleResizeRetryForLease(epoch uint64, source *at
134 }156 }
135 }157 }
136 158
159+func (c *renderCoordinator) scheduleResizeRetryForLease(epoch uint64, source *attachedClient, lease *attachmentLease, run func()) {
160+ reservation, ok := c.reserveResizeRetryForLease(epoch, source, lease, run)
161+ if !ok {
162+ return
163+ }
164+ reservation.finish()
165+}
166+
137 func (c *renderCoordinator) retryCurrentForLease(epoch uint64, source *attachedClient, lease *attachmentLease) bool {167 func (c *renderCoordinator) retryCurrentForLease(epoch uint64, source *attachedClient, lease *attachmentLease) bool {
138 c.mu.Lock()168 c.mu.Lock()
139 defer c.mu.Unlock()169 defer c.mu.Unlock()
M internal/usecase/daemon/transactional_resize.go +411 -88
@@ -8,12 +8,22 @@ import (
88 "github.com/bnema/vev/internal/usecase/layout"
99 )
1010
11+type resizeOwnerPostEffect uint8
12+
13+const (
14+ resizeOwnerPostSnapshotDirty resizeOwnerPostEffect = iota + 1
15+ resizeOwnerPostRetrySchedule
16+ resizeOwnerPostRenderInvalidation
17+ resizeOwnerPostCommitPublication
18+)
19+
1120 // resizeMember is an unpublished layout decision. Prepare only reads guarded
1221 // state; apply performs the external PTY call; commit is the sole publisher.
1322 type resizeMember struct {
1423 session *session
1524 tab *tab
1625 pane *pane
26+ owner paneEffectLease
1727 rect domain.Rect
1828 floating floatingGeometry
1929 isFloating bool
@@ -35,6 +45,11 @@ type preparedTabLayout struct {
3545 members []resizeMember
3646 }
3747
48+type resizeApplyResult struct {
49+ members []resizeMember
50+ failed []resizeMember
51+}
52+
3853 func prepareTabLayoutLocked(sess *session, tb *tab) preparedTabLayout {
3954 return prepareTabLayoutForSizeLocked(sess, tb, tb.size)
4055 }
@@ -58,7 +73,10 @@ func prepareTabLayoutForSizeLocked(sess *session, tb *tab, size domain.Size) pre
5873 continue
5974 }
6075 if p := tb.panes[placement.ID]; p != nil {
61- plan.members = append(plan.members, resizeMember{session: sess, tab: tb, pane: p, rect: placement.Content})
76+ p.mu.Lock()
77+ owner := p.effectLeaseLocked()
78+ p.mu.Unlock()
79+ plan.members = append(plan.members, resizeMember{session: sess, tab: tb, pane: p, owner: owner, rect: placement.Content})
6280 }
6381 }
6482 return plan
@@ -72,6 +90,13 @@ func (d *Daemon) applyPreparedTabMembers(plan *preparedTabLayout) {
7290 p := member.pane
7391 p.resizeMu.Lock()
7492 p.mu.Lock()
93+ if !resizeMemberOwnerCurrentLocked(member) {
94+ member.retry = p.owner.Load() != nil
95+ p.resizeRetry = member.retry
96+ p.mu.Unlock()
97+ p.resizeMu.Unlock()
98+ continue
99+ }
75100 old := p.rect
76101 pty := p.pty
77102 // A rejected earlier attempt deliberately leaves its parser gate open.
@@ -88,36 +113,102 @@ func (d *Daemon) applyPreparedTabMembers(plan *preparedTabLayout) {
88113 } else if err := pty.Resize(rectSize(member.rect)); err != nil {
89114 d.log.Warn("pty resize failed", "err", err)
90115 p.mu.Lock()
116+ current := resizeMemberOwnerCurrentLocked(member)
91117 p.resizeRetry = true
92118 p.mu.Unlock()
93119 d.replayResizePending(member.session, member.tab, p, false, member.rect)
94120 member.retry = true
95- member.err = err
121+ if current {
122+ member.err = err
123+ }
96124 } else {
97- member.ok = true
125+ p.mu.Lock()
126+ current := resizeMemberOwnerCurrentLocked(member)
127+ if !current && p.owner.Load() != nil {
128+ p.resizeRetry = true
129+ member.retry = true
130+ }
131+ p.mu.Unlock()
132+ member.ok = current
98133 member.screenResized = true // records that this member owns an open gate
99134 }
100135 p.resizeMu.Unlock()
101136 }
102137 }
103138
139+func resizeMemberOwnerCurrentLocked(member *resizeMember) bool {
140+ if member == nil || member.pane == nil {
141+ return false
142+ }
143+ // A zero lease is retained only for focused tests which construct the
144+ // transactional primitive directly. Every prepared production member owns
145+ // an immutable generation lease.
146+ if member.owner.owner == nil {
147+ return true
148+ }
149+ owner := member.pane.owner.Load()
150+ return owner == member.owner.owner && owner.session == member.session && owner.tab == member.tab
151+}
152+
104153 func validatePreparedTabLayoutLocked(tb *tab, plan *preparedTabLayout) bool {
105154 if tb.layoutGeneration != plan.generation || tb.size != plan.previousSize {
106155 return false
107156 }
108157 for i := range plan.members {
109158 member := &plan.members[i]
110- if tb.panes[member.pane.id] != member.pane {
159+ if tb.panes[member.pane.id] != member.pane || !resizeMemberOwnerCurrentLocked(member) {
111160 return false
112161 }
113162 }
114163 return true
115164 }
116165
117-func commitPreparedTabLayoutLocked(plan *preparedTabLayout) {
166+func resizeMembersOwnerCurrent(members []resizeMember) bool {
167+ for i := range members {
168+ if !resizeMemberOwnerCurrentLocked(&members[i]) {
169+ return false
170+ }
171+ }
172+ return true
173+}
174+
175+func (d *Daemon) publishResizeOwnerInvalidation(members []resizeMember, sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64, inv renderInvalidation) bool {
176+ var reservation *renderInvalidationReservation
177+ fallback := false
178+ if !d.publishResizeOwnerPostEffect(members, resizeOwnerPostRenderInvalidation, func() {
179+ if rc := sess.renderCoordinator(); rc != nil {
180+ reservation, _ = rc.reserveInvalidationForLeaseAtResizeEpoch(ac, lease, epoch, inv)
181+ return
182+ }
183+ fallback = ac != nil
184+ }) {
185+ return false
186+ }
187+ if reservation != nil {
188+ reservation.finish()
189+ return true
190+ }
191+ if fallback {
192+ d.paint(sess, ac, inv.reset, nil)
193+ return true
194+ }
195+ return ac == nil
196+}
197+
198+func preparedTabOwnerStale(plan *preparedTabLayout) bool {
199+ return plan != nil && !resizeMembersOwnerCurrent(plan.members)
200+}
201+
202+func commitPreparedTabLayoutLocked(plan *preparedTabLayout) bool {
118203 for i := range plan.members {
119204 member := &plan.members[i]
120205 member.pane.mu.Lock()
206+ if !resizeMemberOwnerCurrentLocked(member) {
207+ member.pane.resizeRetry = member.pane.owner.Load() != nil
208+ member.retry = member.pane.resizeRetry
209+ member.pane.mu.Unlock()
210+ return false
211+ }
121212 member.pane.rect = member.rect
122213 if member.ok {
123214 member.pane.resizeRetry = false
@@ -125,6 +216,7 @@ func commitPreparedTabLayoutLocked(plan *preparedTabLayout) {
125216 }
126217 member.pane.mu.Unlock()
127218 }
219+ return true
128220 }
129221
130222 // cancelStalePreparedGates releases gates for members which disappeared from
@@ -173,22 +265,22 @@ func (d *Daemon) finishPreparedTabMembers(plan *preparedTabLayout, accepted bool
173265 // applyTabLayoutTransaction is the canonical tiled-layout publisher. It owns
174266 // one per-tab single-writer loop, but never holds tab or pane state locks while
175267 // invoking PTY.Resize.
176-func (d *Daemon) applyTabLayoutTransaction(sess *session, tb *tab, current ...func() bool) ([]resizeMember, bool) {
268+func (d *Daemon) applyTabLayoutTransaction(sess *session, tb *tab, current ...func() bool) (resizeApplyResult, bool) {
177269 return d.applyTabLayoutTransactionWithNotice(sess, tb, true, current...)
178270 }
179271
180-func (d *Daemon) applyTabLayoutTransactionWithNotice(sess *session, tb *tab, reportFailure bool, current ...func() bool) ([]resizeMember, bool) {
272+func (d *Daemon) applyTabLayoutTransactionWithNotice(sess *session, tb *tab, reportFailure bool, current ...func() bool) (resizeApplyResult, bool) {
181273 if tb == nil {
182- return nil, false
274+ return resizeApplyResult{}, false
183275 }
184276 tb.layoutApplyMu.Lock()
185277 defer tb.layoutApplyMu.Unlock()
186278 for {
187279 if tb.ctx != nil && tb.ctx.Err() != nil {
188- return nil, false
280+ return resizeApplyResult{}, false
189281 }
190282 if len(current) != 0 && !current[0]() {
191- return nil, false
283+ return resizeApplyResult{}, false
192284 }
193285 tb.mu.Lock()
194286 plan := prepareTabLayoutLocked(sess, tb)
@@ -201,12 +293,16 @@ func (d *Daemon) applyTabLayoutTransactionWithNotice(sess *session, tb *tab, rep
201293 accepted = false
202294 }
203295 if accepted {
204- commitPreparedTabLayoutLocked(&plan)
296+ accepted = commitPreparedTabLayoutLocked(&plan)
205297 }
206298 tb.mu.Unlock()
207299 d.finishPreparedTabMembers(&plan, accepted)
208300 if !accepted {
209301 d.cancelStalePreparedGates(sess, tb, &plan)
302+ if preparedTabOwnerStale(&plan) {
303+ d.releasePreparedSessionGates([]*preparedTabLayout{&plan})
304+ return resizeApplyResult{}, false
305+ }
210306 if len(current) != 0 && !current[0]() {
211307 for i := range plan.members {
212308 member := &plan.members[i]
@@ -217,7 +313,7 @@ func (d *Daemon) applyTabLayoutTransactionWithNotice(sess *session, tb *tab, rep
217313 d.replayResizePending(member.session, member.tab, member.pane, false, member.rect)
218314 member.pane.resizeMu.Unlock()
219315 }
220- return nil, false
316+ return resizeApplyResult{}, false
221317 }
222318 continue
223319 }
@@ -227,44 +323,104 @@ func (d *Daemon) applyTabLayoutTransactionWithNotice(sess *session, tb *tab, rep
227323 failed = append(failed, member)
228324 }
229325 }
230- if reportFailure && len(failed) != 0 {
326+ if reportFailure && len(failed) != 0 && resizeMembersOwnerCurrent(failed) {
231327 d.notify(sess, domain.NoticeWarn, domain.NoticeResizeFailed,
232328 "pane resize failed; retrying in background", failed[len(failed)-1].err)
233329 }
234- return failed, true
330+ return resizeApplyResult{members: append([]resizeMember(nil), plan.members...), failed: failed}, true
235331 }
236332 }
237333
238334 func (d *Daemon) applyTabLayout(sess *session, tb *tab) bool {
239- failed, ok := d.applyTabLayoutTransaction(sess, tb)
335+ result, ok := d.applyTabLayoutTransaction(sess, tb)
240336 if !ok {
241337 return false
242338 }
243- markSnapshotDirty(sess)
244- if len(failed) != 0 {
245- d.scheduleAcceptedTabLayoutRetry(sess, tb)
339+ if !d.publishResizeOwnerPostEffect(result.members, resizeOwnerPostSnapshotDirty, func() {
340+ markSnapshotDirty(sess)
341+ }) {
342+ return false
343+ }
344+ if len(result.failed) != 0 {
345+ if !d.scheduleAcceptedTabLayoutRetry(sess, tb, result.failed) {
346+ return false
347+ }
246348 }
247349 return true
248350 }
249351
250352 const maxAcceptedTabLayoutRetries = 3
251353
354+// acceptedTabLayoutRetryToken binds a delayed tiled retry to the exact pane
355+// owner generations which accepted the failure. A moved pane cannot lend its
356+// old source worker authority over either the PTY or source publications.
357+type acceptedTabLayoutRetryToken struct {
358+ session *session
359+ tab *tab
360+ members []resizeMember
361+}
362+
363+func newAcceptedTabLayoutRetryToken(sess *session, tb *tab, members []resizeMember) acceptedTabLayoutRetryToken {
364+ return acceptedTabLayoutRetryToken{session: sess, tab: tb, members: append([]resizeMember(nil), members...)}
365+}
366+
367+func (t acceptedTabLayoutRetryToken) current() bool {
368+ if t.session == nil || t.tab == nil || len(t.members) == 0 {
369+ return false
370+ }
371+ for i := range t.members {
372+ member := &t.members[i]
373+ if member.session != t.session || member.tab != t.tab || !resizeMemberOwnerCurrentLocked(member) {
374+ return false
375+ }
376+ }
377+ return true
378+}
379+
252380 // scheduleAcceptedTabLayoutRetry owns one bounded retry worker per tab. The
253381 // worker is deduplicated, derives cancellation from the tab lifecycle, and
254382 // suppresses repeat degradation notices after the accepted initial failure.
255-func (d *Daemon) scheduleAcceptedTabLayoutRetry(sess *session, tb *tab) {
383+func (d *Daemon) scheduleAcceptedTabLayoutRetry(sess *session, tb *tab, failed ...[]resizeMember) bool {
256384 if d.clock == nil || tb == nil || tb.ctx == nil {
257- return
385+ return false
258386 }
259- tb.layoutRetryMu.Lock()
260- if tb.layoutRetryRunning {
261- tb.layoutRetryMu.Unlock()
262- return
387+ var members []resizeMember
388+ if len(failed) != 0 {
389+ members = failed[0]
390+ } else {
391+ tb.mu.Lock()
392+ plan := prepareTabLayoutLocked(sess, tb)
393+ tb.mu.Unlock()
394+ for i := range plan.members {
395+ member := plan.members[i]
396+ member.pane.mu.Lock()
397+ pending := member.pane.resizeRetry
398+ member.pane.mu.Unlock()
399+ if pending {
400+ members = append(members, member)
401+ }
402+ }
403+ }
404+ token := newAcceptedTabLayoutRetryToken(sess, tb, members)
405+ var ctx context.Context
406+ var cancel context.CancelFunc
407+ start := false
408+ if !d.publishResizeOwnerPostEffect(members, resizeOwnerPostRetrySchedule, func() {
409+ tb.layoutRetryMu.Lock()
410+ defer tb.layoutRetryMu.Unlock()
411+ if tb.layoutRetryRunning {
412+ return
413+ }
414+ ctx, cancel = context.WithCancel(tb.ctx)
415+ tb.layoutRetryRunning = true
416+ tb.layoutRetryCancel = cancel
417+ start = true
418+ }) {
419+ return false
420+ }
421+ if !start {
422+ return true
263423 }
264- ctx, cancel := context.WithCancel(tb.ctx)
265- tb.layoutRetryRunning = true
266- tb.layoutRetryCancel = cancel
267- tb.layoutRetryMu.Unlock()
268424
269425 go func() {
270426 defer func() {
@@ -275,6 +431,9 @@ func (d *Daemon) scheduleAcceptedTabLayoutRetry(sess *session, tb *tab) {
275431 tb.layoutRetryMu.Unlock()
276432 }()
277433 for range maxAcceptedTabLayoutRetries {
434+ if !token.current() {
435+ return
436+ }
278437 timer := d.clock.NewTimer(minOutputRenderDeadline)
279438 if timer == nil {
280439 return
@@ -290,22 +449,37 @@ func (d *Daemon) scheduleAcceptedTabLayoutRetry(sess *session, tb *tab) {
290449 return
291450 case <-timerC:
292451 }
293- if ctx.Err() != nil {
452+ if ctx.Err() != nil || !token.current() {
294453 return
295454 }
296- failed, ok := d.applyTabLayoutTransactionWithNotice(sess, tb, false, func() bool { return ctx.Err() == nil })
297- if !ok || len(failed) == 0 {
455+ result, ok := d.applyTabLayoutTransactionWithNotice(sess, tb, false, func() bool {
456+ return ctx.Err() == nil && token.current()
457+ })
458+ if !ok || !token.current() {
459+ return
460+ }
461+ if len(result.failed) == 0 {
462+ return
463+ }
464+ token = newAcceptedTabLayoutRetryToken(sess, tb, result.failed)
465+ if !token.current() {
466+ return
467+ }
468+ if !d.publishResizeOwnerPostEffect(result.members, resizeOwnerPostSnapshotDirty, func() {
469+ markSnapshotDirty(sess)
470+ }) {
298471 return
299472 }
300- markSnapshotDirty(sess)
301473 sess.mu.Lock()
302474 ac := sess.client
303475 sess.mu.Unlock()
304- if ac != nil {
305- d.invalidateRender(sess, ac, true, "transactional_resize.go")
476+ if ac != nil && !d.publishResizeOwnerInvalidation(result.members, sess, ac, nil, 0,
477+ renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) {
478+ return
306479 }
307480 }
308481 }()
482+ return true
309483 }
310484
311485 // replayResizePending completes a pane's apply epoch. It keeps the gate set
@@ -344,22 +518,22 @@ func (d *Daemon) replayResizePending(sess *session, tb *tab, p *pane, resized bo
344518 // used for retryable PTY work. Floating state is not part of a tiled layout
345519 // generation, so its slot generation and exact pane identity validate its
346520 // publication instead.
347-func (d *Daemon) applyVisibleFloatingLayout(sess *session, tb *tab, current func() bool) ([]resizeMember, bool) {
521+func (d *Daemon) applyVisibleFloatingLayout(sess *session, tb *tab, current func() bool) (resizeApplyResult, bool) {
348522 return d.applyVisibleFloatingLayoutForMember(sess, tb, current, nil)
349523 }
350524
351525 // applyVisibleFloatingLayoutForMember optionally fences a delayed retry to the
352526 // exact failed floating slot. Unlike tiled panes, a popup is not in tb.panes,
353527 // so accepting a replacement here would silently retry unrelated geometry.
354-func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, current func() bool, expected *resizeMember) ([]resizeMember, bool) {
528+func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, current func() bool, expected *resizeMember) (resizeApplyResult, bool) {
355529 if tb == nil {
356- return nil, true
530+ return resizeApplyResult{}, true
357531 }
358532 tb.mu.Lock()
359533 if tb.floating.state != floatingVisible || tb.floating.pane == nil ||
360534 (expected != nil && (tb.floating.generation != expected.floatingGeneration || tb.floating.pane != expected.pane)) {
361535 tb.mu.Unlock()
362- return nil, true
536+ return resizeApplyResult{}, true
363537 }
364538 p := tb.floating.pane
365539 generation := tb.floating.generation
@@ -367,36 +541,44 @@ func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, cur
367541 geometry := calculateContentFloatingGeometry(size, d.currentFloatingConfig())
368542 tb.mu.Unlock()
369543 if !geometry.valid() {
370- return nil, true
544+ return resizeApplyResult{}, true
371545 }
372546
373547 // This keeps successful PTY resizes gated until the
374548 // floating slot and tab size are revalidated. A newer client resize may
375549 // otherwise publish this obsolete popup geometry after its PTY call returns.
550+ p.mu.Lock()
551+ owner := p.effectLeaseLocked()
552+ p.mu.Unlock()
376553 plan := preparedTabLayout{members: []resizeMember{{
377- session: sess, tab: tb, pane: p, rect: geometry.Inner, floating: geometry, isFloating: true, floatingGeneration: generation,
554+ session: sess, tab: tb, pane: p, owner: owner, rect: geometry.Inner, floating: geometry, isFloating: true, floatingGeneration: generation,
378555 }}}
379556 d.applyPreparedTabMembers(&plan)
380557
381558 // The PTY may have accepted an intermediate size, but a hidden, replaced,
382559 // relaunched, or resized slot must never receive this attempt's geometry.
383560 tb.mu.Lock()
384- currentSlot := tb.floating.state == floatingVisible && tb.floating.generation == generation && tb.floating.pane == p && tb.size == size
561+ currentSlot := tb.floating.state == floatingVisible && tb.floating.generation == generation && tb.floating.pane == p && tb.size == size &&
562+ resizeMemberOwnerCurrentLocked(&plan.members[0])
385563 if current != nil && !current() {
386564 currentSlot = false
387565 }
388566 if currentSlot {
389567 p.mu.Lock()
390- p.rect = geometry.Inner
391- p.popupGeometry = geometry
392- if plan.members[0].ok {
393- p.resizeRetry = false
394- p.screen.Resize(geometry.Inner.Width, geometry.Inner.Height)
568+ currentSlot = resizeMemberOwnerCurrentLocked(&plan.members[0])
569+ if currentSlot {
570+ p.rect = geometry.Inner
571+ p.popupGeometry = geometry
572+ if plan.members[0].ok {
573+ p.resizeRetry = false
574+ p.screen.Resize(geometry.Inner.Width, geometry.Inner.Height)
575+ }
395576 }
396577 p.mu.Unlock()
397578 }
398579 tb.mu.Unlock()
399580 if !currentSlot {
581+ ownerCurrent := resizeMemberOwnerCurrentLocked(&plan.members[0])
400582 // This attempt has no tiled transaction loop to retain the gate for, so
401583 // discard its buffered bytes at the old screen size before returning.
402584 for i := range plan.members {
@@ -408,16 +590,17 @@ func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, cur
408590 d.replayResizePending(member.session, member.tab, member.pane, false, member.rect)
409591 member.pane.resizeMu.Unlock()
410592 }
411- if current != nil && !current() {
412- return nil, false
593+ if !ownerCurrent || (current != nil && !current()) {
594+ return resizeApplyResult{}, false
413595 }
414- return nil, true
596+ return resizeApplyResult{}, true
415597 }
416598 if plan.members[0].ok {
417599 d.finishPreparedTabMembers(&plan, true)
418- return nil, true
600+ return resizeApplyResult{members: append([]resizeMember(nil), plan.members...)}, true
419601 }
420- return plan.members, true
602+ members := append([]resizeMember(nil), plan.members...)
603+ return resizeApplyResult{members: members, failed: members}, true
421604 }
422605
423606 // releasePreparedSessionGates abandons a session attempt which cannot be
@@ -446,9 +629,9 @@ func (d *Daemon) releasePreparedSessionGates(plans []*preparedTabLayout) {
446629 // transaction: all PTYs are applied and plans validated first, then the
447630 // coordinator admits the epoch before any tab size, rectangle, VT screen,
448631 // snapshot dirtiness, or resize telemetry becomes visible.
449-func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, admit func() bool) ([]resizeMember, bool) {
632+func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, admit func() bool) (resizeApplyResult, bool) {
450633 if sess == nil {
451- return nil, false
634+ return resizeApplyResult{}, false
452635 }
453636 sess.layoutApplyMu.Lock()
454637 defer sess.layoutApplyMu.Unlock()
@@ -456,10 +639,10 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad
456639 target := tabSize(size)
457640 for {
458641 if sess.ctx != nil && sess.ctx.Err() != nil {
459- return nil, false
642+ return resizeApplyResult{}, false
460643 }
461644 if current != nil && !current() {
462- return nil, false
645+ return resizeApplyResult{}, false
463646 }
464647 sess.mu.Lock()
465648 tabs := append([]*tab(nil), sess.tabs...)
@@ -506,8 +689,15 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad
506689 }
507690 if valid {
508691 for _, plan := range plans {
509- plan.tab.size = plan.size
510- commitPreparedTabLayoutLocked(plan)
692+ if !commitPreparedTabLayoutLocked(plan) {
693+ valid = false
694+ break
695+ }
696+ }
697+ if valid {
698+ for _, plan := range plans {
699+ plan.tab.size = plan.size
700+ }
511701 }
512702 }
513703 for i := len(tabs) - 1; i >= 0; i-- {
@@ -515,20 +705,27 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad
515705 }
516706 if !valid {
517707 d.releasePreparedSessionGates(plans)
708+ for _, plan := range plans {
709+ if preparedTabOwnerStale(plan) {
710+ return resizeApplyResult{}, false
711+ }
712+ }
518713 // Headless requests have no attachment epoch. A live tab mutation
519714 // still invalidates their plan, but must trigger a fresh prepare rather
520715 // than being mistaken for a canceled request.
521716 if current != nil && !current() {
522- return nil, false
717+ return resizeApplyResult{}, false
523718 }
524719 // A layout mutation invalidated the plans while this epoch remains
525720 // current. Reapply the fresh session geometry before admitting it.
526721 continue
527722 }
528723
724+ members := make([]resizeMember, 0)
529725 failed := make([]resizeMember, 0)
530726 for _, plan := range plans {
531727 d.finishPreparedTabMembers(plan, true)
728+ members = append(members, plan.members...)
532729 for _, member := range plan.members {
533730 if !member.ok {
534731 failed = append(failed, member)
@@ -536,20 +733,114 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad
536733 }
537734 }
538735 if active >= 0 && active < len(tabs) {
539- floatingFailed, ok := d.applyVisibleFloatingLayout(sess, tabs[active], current)
736+ floatingResult, ok := d.applyVisibleFloatingLayout(sess, tabs[active], current)
540737 if !ok {
541- return nil, false
738+ return resizeApplyResult{}, false
542739 }
543- failed = append(failed, floatingFailed...)
740+ members = append(members, floatingResult.members...)
741+ failed = append(failed, floatingResult.failed...)
544742 }
545- if len(failed) != 0 {
743+ if len(failed) != 0 && resizeMembersOwnerCurrent(failed) {
546744 d.notify(sess, domain.NoticeWarn, domain.NoticeResizeFailed,
547745 "pane resize failed; retrying in background", failed[len(failed)-1].err)
548746 }
549- markSnapshotDirty(sess)
550- d.observeRuntime(ports.RuntimeResizeCommitted, 0, true)
551- return failed, true
747+ if len(failed) != 0 && !resizeMembersOwnerCurrent(failed) {
748+ return resizeApplyResult{}, false
749+ }
750+ return resizeApplyResult{members: members, failed: failed}, true
751+ }
752+}
753+
754+type resizeCommitPublication struct {
755+ members []resizeMember
756+ session *session
757+ attachment *attachedClient
758+ lease *attachmentLease
759+ epoch uint64
760+ role attachmentRoleToken
761+ coordinator *renderCoordinator
762+ observer ports.RuntimeObserver
763+ mark ports.RuntimeMark
764+}
765+
766+func (p resizeCommitPublication) current() bool {
767+ if !resizeMembersOwnerCurrent(p.members) {
768+ return false
552769 }
770+ if p.attachment == nil {
771+ return true
772+ }
773+ if p.role.effect == nil || p.role.effect.ended.Load() || p.role.sess != p.session ||
774+ p.role.ac != p.attachment || p.role.role != attachmentActive || p.role.lease != p.lease ||
775+ p.role.generation != p.attachment.roleGeneration.Load() ||
776+ !p.attachment.transportSnapshotCurrent(p.role.transport) ||
777+ p.attachment.currentSession() != p.session || p.coordinator == nil {
778+ return false
779+ }
780+ p.session.mu.Lock()
781+ active := p.session.attachmentRoleLocked(p.attachment) == attachmentActive
782+ p.session.mu.Unlock()
783+ return active && p.coordinator.resizeCurrentForLease(p.epoch, p.attachment, p.lease, false)
784+}
785+
786+// emit performs observer work only after every resize-owner fence and
787+// attachment sendMu are released. The generation-bound publication is checked
788+// once more so a move that wins after geometry publication but before observer
789+// admission suppresses stale source telemetry.
790+func (p resizeCommitPublication) emit() bool {
791+ if !p.current() {
792+ return false
793+ }
794+ if p.observer != nil {
795+ p.observer.ObserveRuntime(p.mark)
796+ }
797+ return true
798+}
799+
800+// publishResizeCommit linearizes attachment geometry and an immutable telemetry
801+// reservation with pane ownership. sendMu is acquired before resize fences, so
802+// the canonical rule never acquires an attachment lock while a pane fence is
803+// held. Observer callbacks run only after both lock families are released.
804+func (d *Daemon) publishResizeCommit(members []resizeMember, sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64, ticket *roleEffectTicket, size domain.Size) bool {
805+ d.observeBeforeResizeOwnerPostEffect(resizeOwnerPostCommitPublication)
806+ if ac != nil {
807+ ac.sendMu.Lock()
808+ }
809+ fences := acquireResizeOwnerPostEffectFences(members)
810+ if fences == nil {
811+ if ac != nil {
812+ ac.sendMu.Unlock()
813+ }
814+ return false
815+ }
816+ publication := resizeCommitPublication{
817+ members: append([]resizeMember(nil), members...),
818+ session: sess,
819+ attachment: ac,
820+ lease: lease,
821+ epoch: epoch,
822+ observer: d.runtimeObserver,
823+ mark: ports.NewRuntimeMark("daemon", ports.RuntimeResizeCommitted, 0, true),
824+ }
825+ if ticket != nil {
826+ publication.role = ticket.roleToken()
827+ publication.coordinator = sess.renderCoordinator()
828+ }
829+ if !publication.current() {
830+ fences.Release()
831+ if ac != nil {
832+ ac.sendMu.Unlock()
833+ }
834+ return false
835+ }
836+ if ac != nil {
837+ ac.size = size
838+ }
839+ fences.Release()
840+ if ac != nil {
841+ ac.sendMu.Unlock()
842+ }
843+ return publication.emit()
553844 }
554845
555846 func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64) bool {
@@ -568,22 +859,34 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *
568859 return false
569860 }
570861 d.exitCopyMode(ac)
571- failed, ok := d.applySessionLayout(sess, snap.size, current, func() bool {
862+ result, ok := d.applySessionLayout(sess, snap.size, current, func() bool {
572863 return rc.resizeCurrentForLease(epoch, ac, lease, true)
573864 })
574865 if !ok {
575866 return false
576867 }
577- ac.sendMu.Lock()
578- ac.size = snap.size
579- ac.sendMu.Unlock()
580- if len(failed) != 0 {
581- rc.scheduleResizeRetryForLease(epoch, ac, lease, func() { d.retryResizeMembers(sess, ac, lease, epoch, failed) })
868+ if !d.publishResizeOwnerPostEffect(result.members, resizeOwnerPostSnapshotDirty, func() {
869+ markSnapshotDirty(sess)
870+ }) {
871+ return false
872+ }
873+ if !d.publishResizeCommit(result.members, sess, ac, lease, epoch, ticket, snap.size) {
874+ return false
875+ }
876+ if len(result.failed) != 0 {
877+ var retry *resizeRetryReservation
878+ if !d.publishResizeOwnerPostEffect(result.failed, resizeOwnerPostRetrySchedule, func() {
879+ retry, _ = rc.reserveResizeRetryForLease(epoch, ac, lease, func() {
880+ d.retryResizeMembers(sess, ac, lease, epoch, result.failed)
881+ })
882+ }) || retry == nil {
883+ return false
884+ }
885+ retry.finish()
582886 }
583887 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
584- // A successful transaction publishes exactly one full S2 frame. The
585- // coordinator is the only emission route and stale epochs never reach it.
586- if !rc.invalidateForLeaseAtResizeEpoch(ac, lease, epoch, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) {
888+ if !d.publishResizeOwnerInvalidation(result.members, sess, ac, lease, epoch,
889+ renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) {
587890 return false
588891 }
589892 // The resize debounce has already elapsed. Consume this sticky reset now;
@@ -613,7 +916,7 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *at
613916 tabs := make([]*tab, 0, len(members))
614917 seen := make(map[*tab]struct{}, len(members))
615918 for _, member := range members {
616- if member.tab == nil || member.isFloating {
919+ if member.tab == nil || member.isFloating || !resizeMemberOwnerCurrentLocked(&member) {
617920 continue
618921 }
619922 if _, ok := seen[member.tab]; ok {
@@ -651,13 +954,13 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *at
651954 // pointers, and solved rectangles, and validates them again before any
652955 // VT/rectangle publication. It also preserves the resize gate and
653956 // degradation notice behavior for another failed external attempt.
654- freshFailed, ok := d.applyTabLayoutTransaction(sess, tb, func() bool {
957+ result, ok := d.applyTabLayoutTransaction(sess, tb, func() bool {
655958 return rc.retryCurrentForLease(epoch, ac, lease)
656959 })
657960 if !ok || !rc.retryCurrentForLease(epoch, ac, lease) {
658961 return
659962 }
660- failed = append(failed, freshFailed...)
963+ failed = append(failed, result.failed...)
661964 // A retry success is a formerly failed, still-current target whose
662965 // canonical apply cleared its retry bit. A collapsed or removed target
663966 // is neither a retry completion nor a reason to publish a reset.
@@ -673,7 +976,7 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *at
673976 tb.mu.Unlock()
674977 }
675978 for _, member := range members {
676- if !member.isFloating || member.tab == nil || member.pane == nil {
979+ if !member.isFloating || member.tab == nil || member.pane == nil || !resizeMemberOwnerCurrentLocked(&member) {
677980 continue
678981 }
679982 if !rc.retryCurrentForLease(epoch, ac, lease) {
@@ -685,7 +988,8 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *at
685988 member.tab.mu.Lock()
686989 currentSlot := member.tab.floating.state == floatingVisible &&
687990 member.tab.floating.generation == member.floatingGeneration &&
688- member.tab.floating.pane == member.pane
991+ member.tab.floating.pane == member.pane &&
992+ resizeMemberOwnerCurrentLocked(&member)
689993 retryPending := false
690994 if currentSlot {
691995 member.pane.mu.Lock()
@@ -696,32 +1000,46 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *at
6961000 if !currentSlot || !retryPending {
6971001 continue
6981002 }
699- freshFailed, ok := d.applyVisibleFloatingLayoutForMember(sess, member.tab, func() bool {
1003+ result, ok := d.applyVisibleFloatingLayoutForMember(sess, member.tab, func() bool {
7001004 return rc.retryCurrentForLease(epoch, ac, lease)
7011005 }, &member)
7021006 if !ok || !rc.retryCurrentForLease(epoch, ac, lease) {
7031007 return
7041008 }
705- failed = append(failed, freshFailed...)
1009+ failed = append(failed, result.failed...)
7061010 member.tab.mu.Lock()
7071011 stillCurrent := member.tab.floating.state == floatingVisible &&
7081012 member.tab.floating.generation == member.floatingGeneration &&
709- member.tab.floating.pane == member.pane
1013+ member.tab.floating.pane == member.pane &&
1014+ resizeMemberOwnerCurrentLocked(&member)
7101015 member.tab.mu.Unlock()
711- if stillCurrent && len(freshFailed) == 0 {
1016+ if stillCurrent && len(result.failed) == 0 {
7121017 member.pane.mu.Lock()
7131018 succeeded = succeeded || !member.pane.resizeRetry
7141019 member.pane.mu.Unlock()
7151020 }
7161021 }
7171022 if len(failed) != 0 {
718- rc.scheduleResizeRetryForLease(epoch, ac, lease, func() { d.retryResizeMembers(sess, ac, lease, epoch, failed) })
1023+ var retry *resizeRetryReservation
1024+ if !d.publishResizeOwnerPostEffect(failed, resizeOwnerPostRetrySchedule, func() {
1025+ retry, _ = rc.reserveResizeRetryForLease(epoch, ac, lease, func() {
1026+ d.retryResizeMembers(sess, ac, lease, epoch, failed)
1027+ })
1028+ }) || retry == nil {
1029+ return
1030+ }
1031+ retry.finish()
7191032 }
7201033 if succeeded {
7211034 // Retry completion changes VT state after the original layout commit.
7221035 // Keep a named session's eventual snapshot generation aligned with it.
723- markSnapshotDirty(sess)
724- rc.invalidateForLease(ac, lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"})
1036+ if !d.publishResizeOwnerPostEffect(members, resizeOwnerPostSnapshotDirty, func() {
1037+ markSnapshotDirty(sess)
1038+ }) {
1039+ return
1040+ }
1041+ d.publishResizeOwnerInvalidation(members, sess, ac, lease, 0,
1042+ renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"})
7251043 }
7261044 }
7271045
@@ -747,8 +1065,13 @@ func (d *Daemon) requestTransactionalResizeForLease(sess *session, ac *attachedC
7471065 if ac == nil {
7481066 // Headless geometry has no coordinator/transport to coalesce, but keeps
7491067 // the same prepare/apply/commit ordering.
750- _, ok := d.applySessionLayout(sess, size, nil, nil)
751- return ok
1068+ result, ok := d.applySessionLayout(sess, size, nil, nil)
1069+ if !ok || !d.publishResizeOwnerPostEffect(result.members, resizeOwnerPostSnapshotDirty, func() {
1070+ markSnapshotDirty(sess)
1071+ }) {
1072+ return false
1073+ }
1074+ return d.publishResizeCommit(result.members, sess, nil, nil, 0, nil, size)
7521075 }
7531076 rc := sess.renderCoordinator()
7541077 if rc == nil || !rc.leaseCurrent(lease, true) {
@@ -8,12 +8,22 @@ import (
8 "github.com/bnema/vev/internal/usecase/layout"8 "github.com/bnema/vev/internal/usecase/layout"
9 )9 )
10 10
11+type resizeOwnerPostEffect uint8
12+
13+const (
14+ resizeOwnerPostSnapshotDirty resizeOwnerPostEffect = iota + 1
15+ resizeOwnerPostRetrySchedule
16+ resizeOwnerPostRenderInvalidation
17+ resizeOwnerPostCommitPublication
18+)
19+
11 // resizeMember is an unpublished layout decision. Prepare only reads guarded20 // resizeMember is an unpublished layout decision. Prepare only reads guarded
12 // state; apply performs the external PTY call; commit is the sole publisher.21 // state; apply performs the external PTY call; commit is the sole publisher.
13 type resizeMember struct {22 type resizeMember struct {
14 session *session23 session *session
15 tab *tab24 tab *tab
16 pane *pane25 pane *pane
26+ owner paneEffectLease
17 rect domain.Rect27 rect domain.Rect
18 floating floatingGeometry28 floating floatingGeometry
19 isFloating bool29 isFloating bool
@@ -35,6 +45,11 @@ type preparedTabLayout struct {
35 members []resizeMember45 members []resizeMember
36 }46 }
37 47
48+type resizeApplyResult struct {
49+ members []resizeMember
50+ failed []resizeMember
51+}
52+
38 func prepareTabLayoutLocked(sess *session, tb *tab) preparedTabLayout {53 func prepareTabLayoutLocked(sess *session, tb *tab) preparedTabLayout {
39 return prepareTabLayoutForSizeLocked(sess, tb, tb.size)54 return prepareTabLayoutForSizeLocked(sess, tb, tb.size)
40 }55 }
@@ -58,7 +73,10 @@ func prepareTabLayoutForSizeLocked(sess *session, tb *tab, size domain.Size) pre
58 continue73 continue
59 }74 }
60 if p := tb.panes[placement.ID]; p != nil {75 if p := tb.panes[placement.ID]; p != nil {
61- plan.members = append(plan.members, resizeMember{session: sess, tab: tb, pane: p, rect: placement.Content})76+ p.mu.Lock()
77+ owner := p.effectLeaseLocked()
78+ p.mu.Unlock()
79+ plan.members = append(plan.members, resizeMember{session: sess, tab: tb, pane: p, owner: owner, rect: placement.Content})
62 }80 }
63 }81 }
64 return plan82 return plan
@@ -72,6 +90,13 @@ func (d *Daemon) applyPreparedTabMembers(plan *preparedTabLayout) {
72 p := member.pane90 p := member.pane
73 p.resizeMu.Lock()91 p.resizeMu.Lock()
74 p.mu.Lock()92 p.mu.Lock()
93+ if !resizeMemberOwnerCurrentLocked(member) {
94+ member.retry = p.owner.Load() != nil
95+ p.resizeRetry = member.retry
96+ p.mu.Unlock()
97+ p.resizeMu.Unlock()
98+ continue
99+ }
75 old := p.rect100 old := p.rect
76 pty := p.pty101 pty := p.pty
77 // A rejected earlier attempt deliberately leaves its parser gate open.102 // A rejected earlier attempt deliberately leaves its parser gate open.
@@ -88,36 +113,102 @@ func (d *Daemon) applyPreparedTabMembers(plan *preparedTabLayout) {
88 } else if err := pty.Resize(rectSize(member.rect)); err != nil {113 } else if err := pty.Resize(rectSize(member.rect)); err != nil {
89 d.log.Warn("pty resize failed", "err", err)114 d.log.Warn("pty resize failed", "err", err)
90 p.mu.Lock()115 p.mu.Lock()
116+ current := resizeMemberOwnerCurrentLocked(member)
91 p.resizeRetry = true117 p.resizeRetry = true
92 p.mu.Unlock()118 p.mu.Unlock()
93 d.replayResizePending(member.session, member.tab, p, false, member.rect)119 d.replayResizePending(member.session, member.tab, p, false, member.rect)
94 member.retry = true120 member.retry = true
95- member.err = err121+ if current {
122+ member.err = err
123+ }
96 } else {124 } else {
97- member.ok = true125+ p.mu.Lock()
126+ current := resizeMemberOwnerCurrentLocked(member)
127+ if !current && p.owner.Load() != nil {
128+ p.resizeRetry = true
129+ member.retry = true
130+ }
131+ p.mu.Unlock()
132+ member.ok = current
98 member.screenResized = true // records that this member owns an open gate133 member.screenResized = true // records that this member owns an open gate
99 }134 }
100 p.resizeMu.Unlock()135 p.resizeMu.Unlock()
101 }136 }
102 }137 }
103 138
139+func resizeMemberOwnerCurrentLocked(member *resizeMember) bool {
140+ if member == nil || member.pane == nil {
141+ return false
142+ }
143+ // A zero lease is retained only for focused tests which construct the
144+ // transactional primitive directly. Every prepared production member owns
145+ // an immutable generation lease.
146+ if member.owner.owner == nil {
147+ return true
148+ }
149+ owner := member.pane.owner.Load()
150+ return owner == member.owner.owner && owner.session == member.session && owner.tab == member.tab
151+}
152+
104 func validatePreparedTabLayoutLocked(tb *tab, plan *preparedTabLayout) bool {153 func validatePreparedTabLayoutLocked(tb *tab, plan *preparedTabLayout) bool {
105 if tb.layoutGeneration != plan.generation || tb.size != plan.previousSize {154 if tb.layoutGeneration != plan.generation || tb.size != plan.previousSize {
106 return false155 return false
107 }156 }
108 for i := range plan.members {157 for i := range plan.members {
109 member := &plan.members[i]158 member := &plan.members[i]
110- if tb.panes[member.pane.id] != member.pane {159+ if tb.panes[member.pane.id] != member.pane || !resizeMemberOwnerCurrentLocked(member) {
111 return false160 return false
112 }161 }
113 }162 }
114 return true163 return true
115 }164 }
116 165
117-func commitPreparedTabLayoutLocked(plan *preparedTabLayout) {166+func resizeMembersOwnerCurrent(members []resizeMember) bool {
167+ for i := range members {
168+ if !resizeMemberOwnerCurrentLocked(&members[i]) {
169+ return false
170+ }
171+ }
172+ return true
173+}
174+
175+func (d *Daemon) publishResizeOwnerInvalidation(members []resizeMember, sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64, inv renderInvalidation) bool {
176+ var reservation *renderInvalidationReservation
177+ fallback := false
178+ if !d.publishResizeOwnerPostEffect(members, resizeOwnerPostRenderInvalidation, func() {
179+ if rc := sess.renderCoordinator(); rc != nil {
180+ reservation, _ = rc.reserveInvalidationForLeaseAtResizeEpoch(ac, lease, epoch, inv)
181+ return
182+ }
183+ fallback = ac != nil
184+ }) {
185+ return false
186+ }
187+ if reservation != nil {
188+ reservation.finish()
189+ return true
190+ }
191+ if fallback {
192+ d.paint(sess, ac, inv.reset, nil)
193+ return true
194+ }
195+ return ac == nil
196+}
197+
198+func preparedTabOwnerStale(plan *preparedTabLayout) bool {
199+ return plan != nil && !resizeMembersOwnerCurrent(plan.members)
200+}
201+
202+func commitPreparedTabLayoutLocked(plan *preparedTabLayout) bool {
118 for i := range plan.members {203 for i := range plan.members {
119 member := &plan.members[i]204 member := &plan.members[i]
120 member.pane.mu.Lock()205 member.pane.mu.Lock()
206+ if !resizeMemberOwnerCurrentLocked(member) {
207+ member.pane.resizeRetry = member.pane.owner.Load() != nil
208+ member.retry = member.pane.resizeRetry
209+ member.pane.mu.Unlock()
210+ return false
211+ }
121 member.pane.rect = member.rect212 member.pane.rect = member.rect
122 if member.ok {213 if member.ok {
123 member.pane.resizeRetry = false214 member.pane.resizeRetry = false
@@ -125,6 +216,7 @@ func commitPreparedTabLayoutLocked(plan *preparedTabLayout) {
125 }216 }
126 member.pane.mu.Unlock()217 member.pane.mu.Unlock()
127 }218 }
219+ return true
128 }220 }
129 221
130 // cancelStalePreparedGates releases gates for members which disappeared from222 // cancelStalePreparedGates releases gates for members which disappeared from
@@ -173,22 +265,22 @@ func (d *Daemon) finishPreparedTabMembers(plan *preparedTabLayout, accepted bool
173 // applyTabLayoutTransaction is the canonical tiled-layout publisher. It owns265 // applyTabLayoutTransaction is the canonical tiled-layout publisher. It owns
174 // one per-tab single-writer loop, but never holds tab or pane state locks while266 // one per-tab single-writer loop, but never holds tab or pane state locks while
175 // invoking PTY.Resize.267 // invoking PTY.Resize.
176-func (d *Daemon) applyTabLayoutTransaction(sess *session, tb *tab, current ...func() bool) ([]resizeMember, bool) {268+func (d *Daemon) applyTabLayoutTransaction(sess *session, tb *tab, current ...func() bool) (resizeApplyResult, bool) {
177 return d.applyTabLayoutTransactionWithNotice(sess, tb, true, current...)269 return d.applyTabLayoutTransactionWithNotice(sess, tb, true, current...)
178 }270 }
179 271
180-func (d *Daemon) applyTabLayoutTransactionWithNotice(sess *session, tb *tab, reportFailure bool, current ...func() bool) ([]resizeMember, bool) {272+func (d *Daemon) applyTabLayoutTransactionWithNotice(sess *session, tb *tab, reportFailure bool, current ...func() bool) (resizeApplyResult, bool) {
181 if tb == nil {273 if tb == nil {
182- return nil, false274+ return resizeApplyResult{}, false
183 }275 }
184 tb.layoutApplyMu.Lock()276 tb.layoutApplyMu.Lock()
185 defer tb.layoutApplyMu.Unlock()277 defer tb.layoutApplyMu.Unlock()
186 for {278 for {
187 if tb.ctx != nil && tb.ctx.Err() != nil {279 if tb.ctx != nil && tb.ctx.Err() != nil {
188- return nil, false280+ return resizeApplyResult{}, false
189 }281 }
190 if len(current) != 0 && !current[0]() {282 if len(current) != 0 && !current[0]() {
191- return nil, false283+ return resizeApplyResult{}, false
192 }284 }
193 tb.mu.Lock()285 tb.mu.Lock()
194 plan := prepareTabLayoutLocked(sess, tb)286 plan := prepareTabLayoutLocked(sess, tb)
@@ -201,12 +293,16 @@ func (d *Daemon) applyTabLayoutTransactionWithNotice(sess *session, tb *tab, rep
201 accepted = false293 accepted = false
202 }294 }
203 if accepted {295 if accepted {
204- commitPreparedTabLayoutLocked(&plan)296+ accepted = commitPreparedTabLayoutLocked(&plan)
205 }297 }
206 tb.mu.Unlock()298 tb.mu.Unlock()
207 d.finishPreparedTabMembers(&plan, accepted)299 d.finishPreparedTabMembers(&plan, accepted)
208 if !accepted {300 if !accepted {
209 d.cancelStalePreparedGates(sess, tb, &plan)301 d.cancelStalePreparedGates(sess, tb, &plan)
302+ if preparedTabOwnerStale(&plan) {
303+ d.releasePreparedSessionGates([]*preparedTabLayout{&plan})
304+ return resizeApplyResult{}, false
305+ }
210 if len(current) != 0 && !current[0]() {306 if len(current) != 0 && !current[0]() {
211 for i := range plan.members {307 for i := range plan.members {
212 member := &plan.members[i]308 member := &plan.members[i]
@@ -217,7 +313,7 @@ func (d *Daemon) applyTabLayoutTransactionWithNotice(sess *session, tb *tab, rep
217 d.replayResizePending(member.session, member.tab, member.pane, false, member.rect)313 d.replayResizePending(member.session, member.tab, member.pane, false, member.rect)
218 member.pane.resizeMu.Unlock()314 member.pane.resizeMu.Unlock()
219 }315 }
220- return nil, false316+ return resizeApplyResult{}, false
221 }317 }
222 continue318 continue
223 }319 }
@@ -227,44 +323,104 @@ func (d *Daemon) applyTabLayoutTransactionWithNotice(sess *session, tb *tab, rep
227 failed = append(failed, member)323 failed = append(failed, member)
228 }324 }
229 }325 }
230- if reportFailure && len(failed) != 0 {326+ if reportFailure && len(failed) != 0 && resizeMembersOwnerCurrent(failed) {
231 d.notify(sess, domain.NoticeWarn, domain.NoticeResizeFailed,327 d.notify(sess, domain.NoticeWarn, domain.NoticeResizeFailed,
232 "pane resize failed; retrying in background", failed[len(failed)-1].err)328 "pane resize failed; retrying in background", failed[len(failed)-1].err)
233 }329 }
234- return failed, true330+ return resizeApplyResult{members: append([]resizeMember(nil), plan.members...), failed: failed}, true
235 }331 }
236 }332 }
237 333
238 func (d *Daemon) applyTabLayout(sess *session, tb *tab) bool {334 func (d *Daemon) applyTabLayout(sess *session, tb *tab) bool {
239- failed, ok := d.applyTabLayoutTransaction(sess, tb)335+ result, ok := d.applyTabLayoutTransaction(sess, tb)
240 if !ok {336 if !ok {
241 return false337 return false
242 }338 }
243- markSnapshotDirty(sess)339+ if !d.publishResizeOwnerPostEffect(result.members, resizeOwnerPostSnapshotDirty, func() {
244- if len(failed) != 0 {340+ markSnapshotDirty(sess)
245- d.scheduleAcceptedTabLayoutRetry(sess, tb)341+ }) {
342+ return false
343+ }
344+ if len(result.failed) != 0 {
345+ if !d.scheduleAcceptedTabLayoutRetry(sess, tb, result.failed) {
346+ return false
347+ }
246 }348 }
247 return true349 return true
248 }350 }
249 351
250 const maxAcceptedTabLayoutRetries = 3352 const maxAcceptedTabLayoutRetries = 3
251 353
354+// acceptedTabLayoutRetryToken binds a delayed tiled retry to the exact pane
355+// owner generations which accepted the failure. A moved pane cannot lend its
356+// old source worker authority over either the PTY or source publications.
357+type acceptedTabLayoutRetryToken struct {
358+ session *session
359+ tab *tab
360+ members []resizeMember
361+}
362+
363+func newAcceptedTabLayoutRetryToken(sess *session, tb *tab, members []resizeMember) acceptedTabLayoutRetryToken {
364+ return acceptedTabLayoutRetryToken{session: sess, tab: tb, members: append([]resizeMember(nil), members...)}
365+}
366+
367+func (t acceptedTabLayoutRetryToken) current() bool {
368+ if t.session == nil || t.tab == nil || len(t.members) == 0 {
369+ return false
370+ }
371+ for i := range t.members {
372+ member := &t.members[i]
373+ if member.session != t.session || member.tab != t.tab || !resizeMemberOwnerCurrentLocked(member) {
374+ return false
375+ }
376+ }
377+ return true
378+}
379+
252 // scheduleAcceptedTabLayoutRetry owns one bounded retry worker per tab. The380 // scheduleAcceptedTabLayoutRetry owns one bounded retry worker per tab. The
253 // worker is deduplicated, derives cancellation from the tab lifecycle, and381 // worker is deduplicated, derives cancellation from the tab lifecycle, and
254 // suppresses repeat degradation notices after the accepted initial failure.382 // suppresses repeat degradation notices after the accepted initial failure.
255-func (d *Daemon) scheduleAcceptedTabLayoutRetry(sess *session, tb *tab) {383+func (d *Daemon) scheduleAcceptedTabLayoutRetry(sess *session, tb *tab, failed ...[]resizeMember) bool {
256 if d.clock == nil || tb == nil || tb.ctx == nil {384 if d.clock == nil || tb == nil || tb.ctx == nil {
257- return385+ return false
258 }386 }
259- tb.layoutRetryMu.Lock()387+ var members []resizeMember
260- if tb.layoutRetryRunning {388+ if len(failed) != 0 {
261- tb.layoutRetryMu.Unlock()389+ members = failed[0]
262- return390+ } else {
391+ tb.mu.Lock()
392+ plan := prepareTabLayoutLocked(sess, tb)
393+ tb.mu.Unlock()
394+ for i := range plan.members {
395+ member := plan.members[i]
396+ member.pane.mu.Lock()
397+ pending := member.pane.resizeRetry
398+ member.pane.mu.Unlock()
399+ if pending {
400+ members = append(members, member)
401+ }
402+ }
403+ }
404+ token := newAcceptedTabLayoutRetryToken(sess, tb, members)
405+ var ctx context.Context
406+ var cancel context.CancelFunc
407+ start := false
408+ if !d.publishResizeOwnerPostEffect(members, resizeOwnerPostRetrySchedule, func() {
409+ tb.layoutRetryMu.Lock()
410+ defer tb.layoutRetryMu.Unlock()
411+ if tb.layoutRetryRunning {
412+ return
413+ }
414+ ctx, cancel = context.WithCancel(tb.ctx)
415+ tb.layoutRetryRunning = true
416+ tb.layoutRetryCancel = cancel
417+ start = true
418+ }) {
419+ return false
420+ }
421+ if !start {
422+ return true
263 }423 }
264- ctx, cancel := context.WithCancel(tb.ctx)
265- tb.layoutRetryRunning = true
266- tb.layoutRetryCancel = cancel
267- tb.layoutRetryMu.Unlock()
268 424
269 go func() {425 go func() {
270 defer func() {426 defer func() {
@@ -275,6 +431,9 @@ func (d *Daemon) scheduleAcceptedTabLayoutRetry(sess *session, tb *tab) {
275 tb.layoutRetryMu.Unlock()431 tb.layoutRetryMu.Unlock()
276 }()432 }()
277 for range maxAcceptedTabLayoutRetries {433 for range maxAcceptedTabLayoutRetries {
434+ if !token.current() {
435+ return
436+ }
278 timer := d.clock.NewTimer(minOutputRenderDeadline)437 timer := d.clock.NewTimer(minOutputRenderDeadline)
279 if timer == nil {438 if timer == nil {
280 return439 return
@@ -290,22 +449,37 @@ func (d *Daemon) scheduleAcceptedTabLayoutRetry(sess *session, tb *tab) {
290 return449 return
291 case <-timerC:450 case <-timerC:
292 }451 }
293- if ctx.Err() != nil {452+ if ctx.Err() != nil || !token.current() {
294 return453 return
295 }454 }
296- failed, ok := d.applyTabLayoutTransactionWithNotice(sess, tb, false, func() bool { return ctx.Err() == nil })455+ result, ok := d.applyTabLayoutTransactionWithNotice(sess, tb, false, func() bool {
297- if !ok || len(failed) == 0 {456+ return ctx.Err() == nil && token.current()
457+ })
458+ if !ok || !token.current() {
459+ return
460+ }
461+ if len(result.failed) == 0 {
462+ return
463+ }
464+ token = newAcceptedTabLayoutRetryToken(sess, tb, result.failed)
465+ if !token.current() {
466+ return
467+ }
468+ if !d.publishResizeOwnerPostEffect(result.members, resizeOwnerPostSnapshotDirty, func() {
469+ markSnapshotDirty(sess)
470+ }) {
298 return471 return
299 }472 }
300- markSnapshotDirty(sess)
301 sess.mu.Lock()473 sess.mu.Lock()
302 ac := sess.client474 ac := sess.client
303 sess.mu.Unlock()475 sess.mu.Unlock()
304- if ac != nil {476+ if ac != nil && !d.publishResizeOwnerInvalidation(result.members, sess, ac, nil, 0,
305- d.invalidateRender(sess, ac, true, "transactional_resize.go")477+ renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) {
478+ return
306 }479 }
307 }480 }
308 }()481 }()
482+ return true
309 }483 }
310 484
311 // replayResizePending completes a pane's apply epoch. It keeps the gate set485 // replayResizePending completes a pane's apply epoch. It keeps the gate set
@@ -344,22 +518,22 @@ func (d *Daemon) replayResizePending(sess *session, tb *tab, p *pane, resized bo
344 // used for retryable PTY work. Floating state is not part of a tiled layout518 // used for retryable PTY work. Floating state is not part of a tiled layout
345 // generation, so its slot generation and exact pane identity validate its519 // generation, so its slot generation and exact pane identity validate its
346 // publication instead.520 // publication instead.
347-func (d *Daemon) applyVisibleFloatingLayout(sess *session, tb *tab, current func() bool) ([]resizeMember, bool) {521+func (d *Daemon) applyVisibleFloatingLayout(sess *session, tb *tab, current func() bool) (resizeApplyResult, bool) {
348 return d.applyVisibleFloatingLayoutForMember(sess, tb, current, nil)522 return d.applyVisibleFloatingLayoutForMember(sess, tb, current, nil)
349 }523 }
350 524
351 // applyVisibleFloatingLayoutForMember optionally fences a delayed retry to the525 // applyVisibleFloatingLayoutForMember optionally fences a delayed retry to the
352 // exact failed floating slot. Unlike tiled panes, a popup is not in tb.panes,526 // exact failed floating slot. Unlike tiled panes, a popup is not in tb.panes,
353 // so accepting a replacement here would silently retry unrelated geometry.527 // so accepting a replacement here would silently retry unrelated geometry.
354-func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, current func() bool, expected *resizeMember) ([]resizeMember, bool) {528+func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, current func() bool, expected *resizeMember) (resizeApplyResult, bool) {
355 if tb == nil {529 if tb == nil {
356- return nil, true530+ return resizeApplyResult{}, true
357 }531 }
358 tb.mu.Lock()532 tb.mu.Lock()
359 if tb.floating.state != floatingVisible || tb.floating.pane == nil ||533 if tb.floating.state != floatingVisible || tb.floating.pane == nil ||
360 (expected != nil && (tb.floating.generation != expected.floatingGeneration || tb.floating.pane != expected.pane)) {534 (expected != nil && (tb.floating.generation != expected.floatingGeneration || tb.floating.pane != expected.pane)) {
361 tb.mu.Unlock()535 tb.mu.Unlock()
362- return nil, true536+ return resizeApplyResult{}, true
363 }537 }
364 p := tb.floating.pane538 p := tb.floating.pane
365 generation := tb.floating.generation539 generation := tb.floating.generation
@@ -367,36 +541,44 @@ func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, cur
367 geometry := calculateContentFloatingGeometry(size, d.currentFloatingConfig())541 geometry := calculateContentFloatingGeometry(size, d.currentFloatingConfig())
368 tb.mu.Unlock()542 tb.mu.Unlock()
369 if !geometry.valid() {543 if !geometry.valid() {
370- return nil, true544+ return resizeApplyResult{}, true
371 }545 }
372 546
373 // This keeps successful PTY resizes gated until the547 // This keeps successful PTY resizes gated until the
374 // floating slot and tab size are revalidated. A newer client resize may548 // floating slot and tab size are revalidated. A newer client resize may
375 // otherwise publish this obsolete popup geometry after its PTY call returns.549 // otherwise publish this obsolete popup geometry after its PTY call returns.
550+ p.mu.Lock()
551+ owner := p.effectLeaseLocked()
552+ p.mu.Unlock()
376 plan := preparedTabLayout{members: []resizeMember{{553 plan := preparedTabLayout{members: []resizeMember{{
377- session: sess, tab: tb, pane: p, rect: geometry.Inner, floating: geometry, isFloating: true, floatingGeneration: generation,554+ session: sess, tab: tb, pane: p, owner: owner, rect: geometry.Inner, floating: geometry, isFloating: true, floatingGeneration: generation,
378 }}}555 }}}
379 d.applyPreparedTabMembers(&plan)556 d.applyPreparedTabMembers(&plan)
380 557
381 // The PTY may have accepted an intermediate size, but a hidden, replaced,558 // The PTY may have accepted an intermediate size, but a hidden, replaced,
382 // relaunched, or resized slot must never receive this attempt's geometry.559 // relaunched, or resized slot must never receive this attempt's geometry.
383 tb.mu.Lock()560 tb.mu.Lock()
384- currentSlot := tb.floating.state == floatingVisible && tb.floating.generation == generation && tb.floating.pane == p && tb.size == size561+ currentSlot := tb.floating.state == floatingVisible && tb.floating.generation == generation && tb.floating.pane == p && tb.size == size &&
562+ resizeMemberOwnerCurrentLocked(&plan.members[0])
385 if current != nil && !current() {563 if current != nil && !current() {
386 currentSlot = false564 currentSlot = false
387 }565 }
388 if currentSlot {566 if currentSlot {
389 p.mu.Lock()567 p.mu.Lock()
390- p.rect = geometry.Inner568+ currentSlot = resizeMemberOwnerCurrentLocked(&plan.members[0])
391- p.popupGeometry = geometry569+ if currentSlot {
392- if plan.members[0].ok {570+ p.rect = geometry.Inner
393- p.resizeRetry = false571+ p.popupGeometry = geometry
394- p.screen.Resize(geometry.Inner.Width, geometry.Inner.Height)572+ if plan.members[0].ok {
573+ p.resizeRetry = false
574+ p.screen.Resize(geometry.Inner.Width, geometry.Inner.Height)
575+ }
395 }576 }
396 p.mu.Unlock()577 p.mu.Unlock()
397 }578 }
398 tb.mu.Unlock()579 tb.mu.Unlock()
399 if !currentSlot {580 if !currentSlot {
581+ ownerCurrent := resizeMemberOwnerCurrentLocked(&plan.members[0])
400 // This attempt has no tiled transaction loop to retain the gate for, so582 // This attempt has no tiled transaction loop to retain the gate for, so
401 // discard its buffered bytes at the old screen size before returning.583 // discard its buffered bytes at the old screen size before returning.
402 for i := range plan.members {584 for i := range plan.members {
@@ -408,16 +590,17 @@ func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, cur
408 d.replayResizePending(member.session, member.tab, member.pane, false, member.rect)590 d.replayResizePending(member.session, member.tab, member.pane, false, member.rect)
409 member.pane.resizeMu.Unlock()591 member.pane.resizeMu.Unlock()
410 }592 }
411- if current != nil && !current() {593+ if !ownerCurrent || (current != nil && !current()) {
412- return nil, false594+ return resizeApplyResult{}, false
413 }595 }
414- return nil, true596+ return resizeApplyResult{}, true
415 }597 }
416 if plan.members[0].ok {598 if plan.members[0].ok {
417 d.finishPreparedTabMembers(&plan, true)599 d.finishPreparedTabMembers(&plan, true)
418- return nil, true600+ return resizeApplyResult{members: append([]resizeMember(nil), plan.members...)}, true
419 }601 }
420- return plan.members, true602+ members := append([]resizeMember(nil), plan.members...)
603+ return resizeApplyResult{members: members, failed: members}, true
421 }604 }
422 605
423 // releasePreparedSessionGates abandons a session attempt which cannot be606 // releasePreparedSessionGates abandons a session attempt which cannot be
@@ -446,9 +629,9 @@ func (d *Daemon) releasePreparedSessionGates(plans []*preparedTabLayout) {
446 // transaction: all PTYs are applied and plans validated first, then the629 // transaction: all PTYs are applied and plans validated first, then the
447 // coordinator admits the epoch before any tab size, rectangle, VT screen,630 // coordinator admits the epoch before any tab size, rectangle, VT screen,
448 // snapshot dirtiness, or resize telemetry becomes visible.631 // snapshot dirtiness, or resize telemetry becomes visible.
449-func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, admit func() bool) ([]resizeMember, bool) {632+func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, admit func() bool) (resizeApplyResult, bool) {
450 if sess == nil {633 if sess == nil {
451- return nil, false634+ return resizeApplyResult{}, false
452 }635 }
453 sess.layoutApplyMu.Lock()636 sess.layoutApplyMu.Lock()
454 defer sess.layoutApplyMu.Unlock()637 defer sess.layoutApplyMu.Unlock()
@@ -456,10 +639,10 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad
456 target := tabSize(size)639 target := tabSize(size)
457 for {640 for {
458 if sess.ctx != nil && sess.ctx.Err() != nil {641 if sess.ctx != nil && sess.ctx.Err() != nil {
459- return nil, false642+ return resizeApplyResult{}, false
460 }643 }
461 if current != nil && !current() {644 if current != nil && !current() {
462- return nil, false645+ return resizeApplyResult{}, false
463 }646 }
464 sess.mu.Lock()647 sess.mu.Lock()
465 tabs := append([]*tab(nil), sess.tabs...)648 tabs := append([]*tab(nil), sess.tabs...)
@@ -506,8 +689,15 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad
506 }689 }
507 if valid {690 if valid {
508 for _, plan := range plans {691 for _, plan := range plans {
509- plan.tab.size = plan.size692+ if !commitPreparedTabLayoutLocked(plan) {
510- commitPreparedTabLayoutLocked(plan)693+ valid = false
694+ break
695+ }
696+ }
697+ if valid {
698+ for _, plan := range plans {
699+ plan.tab.size = plan.size
700+ }
511 }701 }
512 }702 }
513 for i := len(tabs) - 1; i >= 0; i-- {703 for i := len(tabs) - 1; i >= 0; i-- {
@@ -515,20 +705,27 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad
515 }705 }
516 if !valid {706 if !valid {
517 d.releasePreparedSessionGates(plans)707 d.releasePreparedSessionGates(plans)
708+ for _, plan := range plans {
709+ if preparedTabOwnerStale(plan) {
710+ return resizeApplyResult{}, false
711+ }
712+ }
518 // Headless requests have no attachment epoch. A live tab mutation713 // Headless requests have no attachment epoch. A live tab mutation
519 // still invalidates their plan, but must trigger a fresh prepare rather714 // still invalidates their plan, but must trigger a fresh prepare rather
520 // than being mistaken for a canceled request.715 // than being mistaken for a canceled request.
521 if current != nil && !current() {716 if current != nil && !current() {
522- return nil, false717+ return resizeApplyResult{}, false
523 }718 }
524 // A layout mutation invalidated the plans while this epoch remains719 // A layout mutation invalidated the plans while this epoch remains
525 // current. Reapply the fresh session geometry before admitting it.720 // current. Reapply the fresh session geometry before admitting it.
526 continue721 continue
527 }722 }
528 723
724+ members := make([]resizeMember, 0)
529 failed := make([]resizeMember, 0)725 failed := make([]resizeMember, 0)
530 for _, plan := range plans {726 for _, plan := range plans {
531 d.finishPreparedTabMembers(plan, true)727 d.finishPreparedTabMembers(plan, true)
728+ members = append(members, plan.members...)
532 for _, member := range plan.members {729 for _, member := range plan.members {
533 if !member.ok {730 if !member.ok {
534 failed = append(failed, member)731 failed = append(failed, member)
@@ -536,20 +733,114 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad
536 }733 }
537 }734 }
538 if active >= 0 && active < len(tabs) {735 if active >= 0 && active < len(tabs) {
539- floatingFailed, ok := d.applyVisibleFloatingLayout(sess, tabs[active], current)736+ floatingResult, ok := d.applyVisibleFloatingLayout(sess, tabs[active], current)
540 if !ok {737 if !ok {
541- return nil, false738+ return resizeApplyResult{}, false
542 }739 }
543- failed = append(failed, floatingFailed...)740+ members = append(members, floatingResult.members...)
741+ failed = append(failed, floatingResult.failed...)
544 }742 }
545- if len(failed) != 0 {743+ if len(failed) != 0 && resizeMembersOwnerCurrent(failed) {
546 d.notify(sess, domain.NoticeWarn, domain.NoticeResizeFailed,744 d.notify(sess, domain.NoticeWarn, domain.NoticeResizeFailed,
547 "pane resize failed; retrying in background", failed[len(failed)-1].err)745 "pane resize failed; retrying in background", failed[len(failed)-1].err)
548 }746 }
549- markSnapshotDirty(sess)747+ if len(failed) != 0 && !resizeMembersOwnerCurrent(failed) {
550- d.observeRuntime(ports.RuntimeResizeCommitted, 0, true)748+ return resizeApplyResult{}, false
551- return failed, true749+ }
750+ return resizeApplyResult{members: members, failed: failed}, true
751+ }
752+}
753+
754+type resizeCommitPublication struct {
755+ members []resizeMember
756+ session *session
757+ attachment *attachedClient
758+ lease *attachmentLease
759+ epoch uint64
760+ role attachmentRoleToken
761+ coordinator *renderCoordinator
762+ observer ports.RuntimeObserver
763+ mark ports.RuntimeMark
764+}
765+
766+func (p resizeCommitPublication) current() bool {
767+ if !resizeMembersOwnerCurrent(p.members) {
768+ return false
552 }769 }
770+ if p.attachment == nil {
771+ return true
772+ }
773+ if p.role.effect == nil || p.role.effect.ended.Load() || p.role.sess != p.session ||
774+ p.role.ac != p.attachment || p.role.role != attachmentActive || p.role.lease != p.lease ||
775+ p.role.generation != p.attachment.roleGeneration.Load() ||
776+ !p.attachment.transportSnapshotCurrent(p.role.transport) ||
777+ p.attachment.currentSession() != p.session || p.coordinator == nil {
778+ return false
779+ }
780+ p.session.mu.Lock()
781+ active := p.session.attachmentRoleLocked(p.attachment) == attachmentActive
782+ p.session.mu.Unlock()
783+ return active && p.coordinator.resizeCurrentForLease(p.epoch, p.attachment, p.lease, false)
784+}
785+
786+// emit performs observer work only after every resize-owner fence and
787+// attachment sendMu are released. The generation-bound publication is checked
788+// once more so a move that wins after geometry publication but before observer
789+// admission suppresses stale source telemetry.
790+func (p resizeCommitPublication) emit() bool {
791+ if !p.current() {
792+ return false
793+ }
794+ if p.observer != nil {
795+ p.observer.ObserveRuntime(p.mark)
796+ }
797+ return true
798+}
799+
800+// publishResizeCommit linearizes attachment geometry and an immutable telemetry
801+// reservation with pane ownership. sendMu is acquired before resize fences, so
802+// the canonical rule never acquires an attachment lock while a pane fence is
803+// held. Observer callbacks run only after both lock families are released.
804+func (d *Daemon) publishResizeCommit(members []resizeMember, sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64, ticket *roleEffectTicket, size domain.Size) bool {
805+ d.observeBeforeResizeOwnerPostEffect(resizeOwnerPostCommitPublication)
806+ if ac != nil {
807+ ac.sendMu.Lock()
808+ }
809+ fences := acquireResizeOwnerPostEffectFences(members)
810+ if fences == nil {
811+ if ac != nil {
812+ ac.sendMu.Unlock()
813+ }
814+ return false
815+ }
816+ publication := resizeCommitPublication{
817+ members: append([]resizeMember(nil), members...),
818+ session: sess,
819+ attachment: ac,
820+ lease: lease,
821+ epoch: epoch,
822+ observer: d.runtimeObserver,
823+ mark: ports.NewRuntimeMark("daemon", ports.RuntimeResizeCommitted, 0, true),
824+ }
825+ if ticket != nil {
826+ publication.role = ticket.roleToken()
827+ publication.coordinator = sess.renderCoordinator()
828+ }
829+ if !publication.current() {
830+ fences.Release()
831+ if ac != nil {
832+ ac.sendMu.Unlock()
833+ }
834+ return false
835+ }
836+ if ac != nil {
837+ ac.size = size
838+ }
839+ fences.Release()
840+ if ac != nil {
841+ ac.sendMu.Unlock()
842+ }
843+ return publication.emit()
553 }844 }
554 845
555 func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64) bool {846 func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64) bool {
@@ -568,22 +859,34 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *
568 return false859 return false
569 }860 }
570 d.exitCopyMode(ac)861 d.exitCopyMode(ac)
571- failed, ok := d.applySessionLayout(sess, snap.size, current, func() bool {862+ result, ok := d.applySessionLayout(sess, snap.size, current, func() bool {
572 return rc.resizeCurrentForLease(epoch, ac, lease, true)863 return rc.resizeCurrentForLease(epoch, ac, lease, true)
573 })864 })
574 if !ok {865 if !ok {
575 return false866 return false
576 }867 }
577- ac.sendMu.Lock()868+ if !d.publishResizeOwnerPostEffect(result.members, resizeOwnerPostSnapshotDirty, func() {
578- ac.size = snap.size869+ markSnapshotDirty(sess)
579- ac.sendMu.Unlock()870+ }) {
580- if len(failed) != 0 {871+ return false
581- rc.scheduleResizeRetryForLease(epoch, ac, lease, func() { d.retryResizeMembers(sess, ac, lease, epoch, failed) })872+ }
873+ if !d.publishResizeCommit(result.members, sess, ac, lease, epoch, ticket, snap.size) {
874+ return false
875+ }
876+ if len(result.failed) != 0 {
877+ var retry *resizeRetryReservation
878+ if !d.publishResizeOwnerPostEffect(result.failed, resizeOwnerPostRetrySchedule, func() {
879+ retry, _ = rc.reserveResizeRetryForLease(epoch, ac, lease, func() {
880+ d.retryResizeMembers(sess, ac, lease, epoch, result.failed)
881+ })
882+ }) || retry == nil {
883+ return false
884+ }
885+ retry.finish()
582 }886 }
583 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)887 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
584- // A successful transaction publishes exactly one full S2 frame. The888+ if !d.publishResizeOwnerInvalidation(result.members, sess, ac, lease, epoch,
585- // coordinator is the only emission route and stale epochs never reach it.889+ renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) {
586- if !rc.invalidateForLeaseAtResizeEpoch(ac, lease, epoch, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) {
587 return false890 return false
588 }891 }
589 // The resize debounce has already elapsed. Consume this sticky reset now;892 // The resize debounce has already elapsed. Consume this sticky reset now;
@@ -613,7 +916,7 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *at
613 tabs := make([]*tab, 0, len(members))916 tabs := make([]*tab, 0, len(members))
614 seen := make(map[*tab]struct{}, len(members))917 seen := make(map[*tab]struct{}, len(members))
615 for _, member := range members {918 for _, member := range members {
616- if member.tab == nil || member.isFloating {919+ if member.tab == nil || member.isFloating || !resizeMemberOwnerCurrentLocked(&member) {
617 continue920 continue
618 }921 }
619 if _, ok := seen[member.tab]; ok {922 if _, ok := seen[member.tab]; ok {
@@ -651,13 +954,13 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *at
651 // pointers, and solved rectangles, and validates them again before any954 // pointers, and solved rectangles, and validates them again before any
652 // VT/rectangle publication. It also preserves the resize gate and955 // VT/rectangle publication. It also preserves the resize gate and
653 // degradation notice behavior for another failed external attempt.956 // degradation notice behavior for another failed external attempt.
654- freshFailed, ok := d.applyTabLayoutTransaction(sess, tb, func() bool {957+ result, ok := d.applyTabLayoutTransaction(sess, tb, func() bool {
655 return rc.retryCurrentForLease(epoch, ac, lease)958 return rc.retryCurrentForLease(epoch, ac, lease)
656 })959 })
657 if !ok || !rc.retryCurrentForLease(epoch, ac, lease) {960 if !ok || !rc.retryCurrentForLease(epoch, ac, lease) {
658 return961 return
659 }962 }
660- failed = append(failed, freshFailed...)963+ failed = append(failed, result.failed...)
661 // A retry success is a formerly failed, still-current target whose964 // A retry success is a formerly failed, still-current target whose
662 // canonical apply cleared its retry bit. A collapsed or removed target965 // canonical apply cleared its retry bit. A collapsed or removed target
663 // is neither a retry completion nor a reason to publish a reset.966 // is neither a retry completion nor a reason to publish a reset.
@@ -673,7 +976,7 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *at
673 tb.mu.Unlock()976 tb.mu.Unlock()
674 }977 }
675 for _, member := range members {978 for _, member := range members {
676- if !member.isFloating || member.tab == nil || member.pane == nil {979+ if !member.isFloating || member.tab == nil || member.pane == nil || !resizeMemberOwnerCurrentLocked(&member) {
677 continue980 continue
678 }981 }
679 if !rc.retryCurrentForLease(epoch, ac, lease) {982 if !rc.retryCurrentForLease(epoch, ac, lease) {
@@ -685,7 +988,8 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *at
685 member.tab.mu.Lock()988 member.tab.mu.Lock()
686 currentSlot := member.tab.floating.state == floatingVisible &&989 currentSlot := member.tab.floating.state == floatingVisible &&
687 member.tab.floating.generation == member.floatingGeneration &&990 member.tab.floating.generation == member.floatingGeneration &&
688- member.tab.floating.pane == member.pane991+ member.tab.floating.pane == member.pane &&
992+ resizeMemberOwnerCurrentLocked(&member)
689 retryPending := false993 retryPending := false
690 if currentSlot {994 if currentSlot {
691 member.pane.mu.Lock()995 member.pane.mu.Lock()
@@ -696,32 +1000,46 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *at
696 if !currentSlot || !retryPending {1000 if !currentSlot || !retryPending {
697 continue1001 continue
698 }1002 }
699- freshFailed, ok := d.applyVisibleFloatingLayoutForMember(sess, member.tab, func() bool {1003+ result, ok := d.applyVisibleFloatingLayoutForMember(sess, member.tab, func() bool {
700 return rc.retryCurrentForLease(epoch, ac, lease)1004 return rc.retryCurrentForLease(epoch, ac, lease)
701 }, &member)1005 }, &member)
702 if !ok || !rc.retryCurrentForLease(epoch, ac, lease) {1006 if !ok || !rc.retryCurrentForLease(epoch, ac, lease) {
703 return1007 return
704 }1008 }
705- failed = append(failed, freshFailed...)1009+ failed = append(failed, result.failed...)
706 member.tab.mu.Lock()1010 member.tab.mu.Lock()
707 stillCurrent := member.tab.floating.state == floatingVisible &&1011 stillCurrent := member.tab.floating.state == floatingVisible &&
708 member.tab.floating.generation == member.floatingGeneration &&1012 member.tab.floating.generation == member.floatingGeneration &&
709- member.tab.floating.pane == member.pane1013+ member.tab.floating.pane == member.pane &&
1014+ resizeMemberOwnerCurrentLocked(&member)
710 member.tab.mu.Unlock()1015 member.tab.mu.Unlock()
711- if stillCurrent && len(freshFailed) == 0 {1016+ if stillCurrent && len(result.failed) == 0 {
712 member.pane.mu.Lock()1017 member.pane.mu.Lock()
713 succeeded = succeeded || !member.pane.resizeRetry1018 succeeded = succeeded || !member.pane.resizeRetry
714 member.pane.mu.Unlock()1019 member.pane.mu.Unlock()
715 }1020 }
716 }1021 }
717 if len(failed) != 0 {1022 if len(failed) != 0 {
718- rc.scheduleResizeRetryForLease(epoch, ac, lease, func() { d.retryResizeMembers(sess, ac, lease, epoch, failed) })1023+ var retry *resizeRetryReservation
1024+ if !d.publishResizeOwnerPostEffect(failed, resizeOwnerPostRetrySchedule, func() {
1025+ retry, _ = rc.reserveResizeRetryForLease(epoch, ac, lease, func() {
1026+ d.retryResizeMembers(sess, ac, lease, epoch, failed)
1027+ })
1028+ }) || retry == nil {
1029+ return
1030+ }
1031+ retry.finish()
719 }1032 }
720 if succeeded {1033 if succeeded {
721 // Retry completion changes VT state after the original layout commit.1034 // Retry completion changes VT state after the original layout commit.
722 // Keep a named session's eventual snapshot generation aligned with it.1035 // Keep a named session's eventual snapshot generation aligned with it.
723- markSnapshotDirty(sess)1036+ if !d.publishResizeOwnerPostEffect(members, resizeOwnerPostSnapshotDirty, func() {
724- rc.invalidateForLease(ac, lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"})1037+ markSnapshotDirty(sess)
1038+ }) {
1039+ return
1040+ }
1041+ d.publishResizeOwnerInvalidation(members, sess, ac, lease, 0,
1042+ renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"})
725 }1043 }
726 }1044 }
727 1045
@@ -747,8 +1065,13 @@ func (d *Daemon) requestTransactionalResizeForLease(sess *session, ac *attachedC
747 if ac == nil {1065 if ac == nil {
748 // Headless geometry has no coordinator/transport to coalesce, but keeps1066 // Headless geometry has no coordinator/transport to coalesce, but keeps
749 // the same prepare/apply/commit ordering.1067 // the same prepare/apply/commit ordering.
750- _, ok := d.applySessionLayout(sess, size, nil, nil)1068+ result, ok := d.applySessionLayout(sess, size, nil, nil)
751- return ok1069+ if !ok || !d.publishResizeOwnerPostEffect(result.members, resizeOwnerPostSnapshotDirty, func() {
1070+ markSnapshotDirty(sess)
1071+ }) {
1072+ return false
1073+ }
1074+ return d.publishResizeCommit(result.members, sess, nil, nil, 0, nil, size)
752 }1075 }
753 rc := sess.renderCoordinator()1076 rc := sess.renderCoordinator()
754 if rc == nil || !rc.leaseCurrent(lease, true) {1077 if rc == nil || !rc.leaseCurrent(lease, true) {
M internal/usecase/daemon/transactional_resize_test.go +438 -2
@@ -5,6 +5,7 @@ import (
55 "io"
66 "strings"
77 "sync"
8+ "sync/atomic"
89 "testing"
910 "time"
1011
@@ -271,10 +272,10 @@ func TestApplySessionLayoutReportsResizeFailuresOnce(t *testing.T) {
271272 }
272273 d, sess, _, _ := newManualSessionWithPTYs(t, first, second)
273274
274- failed, ok := d.applySessionLayout(sess, domain.Size{Cols: 100, Rows: 30}, nil, nil)
275+ result, ok := d.applySessionLayout(sess, domain.Size{Cols: 100, Rows: 30}, nil, nil)
275276
276277 require.Equal(t, tc.wantOK, ok)
277- require.Len(t, failed, tc.wantFailed)
278+ require.Len(t, result.failed, tc.wantFailed)
278279 count := 0
279280 for _, notification := range d.notices.history() {
280281 if notification.Code == domain.NoticeResizeFailed {
@@ -406,6 +407,402 @@ func TestTransactionalResizeEpochLifecycleAndRetryContract(t *testing.T) {
406407 // stopped. Only the newest prepared epoch may apply, commit, and emit one full
407408 // frame. coordinatorMockClock is a generated MockClock/MockTimer harness; its
408409 // channels make callback order explicit and do not use wall-clock waits.
410+func publishResizeOwnerMoveUnderFence(t *testing.T, source, destination *session, tb *tab, p *pane) {
411+ t.Helper()
412+ fences := newMovePaneResizeFences(source, destination, tb, tb, p)
413+ require.True(t, fences.acquire(func() bool {
414+ p.mu.Lock()
415+ p.publishOwnerLocked(destination, tb, 0)
416+ p.mu.Unlock()
417+ return true
418+ }))
419+ fences.Release()
420+}
421+
422+func pauseResizeOwnerPostEffect(d *Daemon, effect resizeOwnerPostEffect) (<-chan struct{}, func()) {
423+ entered := make(chan struct{})
424+ release := make(chan struct{})
425+ var once sync.Once
426+ d.beforeResizeOwnerPostEffect = func(got resizeOwnerPostEffect) {
427+ if got != effect {
428+ return
429+ }
430+ once.Do(func() { close(entered) })
431+ <-release
432+ }
433+ return entered, func() { close(release) }
434+}
435+
436+func TestResizeOwnerMoveImmediatelyBeforeSnapshotDirtyDropsStaleSource(t *testing.T) {
437+ d, source, _, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
438+ source.snapEligible.Store(true)
439+ tb := source.activeTab()
440+ p := tb.focusedPane()
441+ tb.mu.Lock()
442+ tb.size = domain.Size{Cols: 100, Rows: 20}
443+ tb.bumpLayoutGenerationLocked()
444+ tb.mu.Unlock()
445+ destination := &session{id: "destination", name: "destination", ctx: source.ctx, tabs: []*tab{tb}}
446+ entered, release := pauseResizeOwnerPostEffect(d, resizeOwnerPostSnapshotDirty)
447+
448+ result := make(chan bool, 1)
449+ go func() { result <- d.applyTabLayout(source, tb) }()
450+ awaitTestCompletion(t, entered, "resize did not pause before snapshot dirtiness")
451+ publishResizeOwnerMoveUnderFence(t, source, destination, tb, p)
452+ release()
453+
454+ require.False(t, awaitTestValue(t, result, "stale source layout did not return"))
455+ require.False(t, source.snapDirty.Load(), "stale source dirtied its snapshot after the move won the fence")
456+}
457+
458+func TestRetryOwnerMoveImmediatelyBeforeScheduleContinuesAtDestination(t *testing.T) {
459+ resizeErr := errors.New("scripted resize failure")
460+ pty := &transactionalResizePTY{errs: []error{resizeErr, resizeErr}}
461+ d, source, _, _ := newManualSessionWithPTYs(t, pty)
462+ clock := &layoutRetryClock{timers: make(chan *layoutRetryTimer, 8)}
463+ d.clock = clock
464+ tb := source.activeTab()
465+ p := tb.focusedPane()
466+ tb.mu.Lock()
467+ tb.size = domain.Size{Cols: 100, Rows: 20}
468+ tb.bumpLayoutGenerationLocked()
469+ tb.mu.Unlock()
470+ destination := &session{id: "destination", name: "destination", ctx: source.ctx, tabs: []*tab{tb}}
471+ entered, release := pauseResizeOwnerPostEffect(d, resizeOwnerPostRetrySchedule)
472+
473+ result := make(chan bool, 1)
474+ go func() { result <- d.applyTabLayout(source, tb) }()
475+ awaitTestCompletion(t, entered, "resize did not pause before retry scheduling")
476+ publishResizeOwnerMoveUnderFence(t, source, destination, tb, p)
477+ release()
478+
479+ require.False(t, awaitTestValue(t, result, "stale source retry publication did not return"))
480+ tb.layoutRetryMu.Lock()
481+ require.False(t, tb.layoutRetryRunning, "stale source published a retry worker")
482+ tb.layoutRetryMu.Unlock()
483+ for {
484+ select {
485+ case timer := <-clock.timers:
486+ require.NotEqual(t, minOutputRenderDeadline, timer.delay, "stale source armed a retry timer")
487+ default:
488+ goto timersDrained
489+ }
490+ }
491+
492+timersDrained:
493+ d.beforeResizeOwnerPostEffect = nil
494+ require.True(t, d.applyTabLayout(destination, tb), "destination must recreate retry from pane metadata")
495+ for {
496+ timer := awaitTestValue(t, clock.timers, "destination did not continue the moved retry")
497+ if timer.delay == minOutputRenderDeadline {
498+ break
499+ }
500+ }
501+}
502+
503+type resizeFenceCheckingClock struct {
504+ locks []*sync.Mutex
505+ calls atomic.Int32
506+ locksFree atomic.Bool
507+}
508+
509+func (c *resizeFenceCheckingClock) check() {
510+ c.calls.Add(1)
511+ locked := make([]*sync.Mutex, 0, len(c.locks))
512+ for _, lock := range c.locks {
513+ if !lock.TryLock() {
514+ for i := len(locked) - 1; i >= 0; i-- {
515+ locked[i].Unlock()
516+ }
517+ c.locksFree.Store(false)
518+ return
519+ }
520+ locked = append(locked, lock)
521+ }
522+ for i := len(locked) - 1; i >= 0; i-- {
523+ locked[i].Unlock()
524+ }
525+}
526+
527+func (c *resizeFenceCheckingClock) Now() time.Time {
528+ c.check()
529+ return time.Time{}
530+}
531+
532+func (c *resizeFenceCheckingClock) NewTimer(time.Duration) ports.Timer {
533+ c.check()
534+ return stubTimer{}
535+}
536+
537+type blockingResizeCommitObserver struct {
538+ entered chan struct{}
539+ release chan struct{}
540+ once sync.Once
541+}
542+
543+func (o *blockingResizeCommitObserver) ObserveRuntime(mark ports.RuntimeMark) {
544+ if mark.Kind != ports.RuntimeResizeCommitted {
545+ return
546+ }
547+ o.once.Do(func() { close(o.entered) })
548+ <-o.release
549+}
550+
551+func TestResizeCommitObserverBlocksWithoutHoldingOwnerOrAttachmentFences(t *testing.T) {
552+ d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
553+ observer := &blockingResizeCommitObserver{entered: make(chan struct{}), release: make(chan struct{})}
554+ d.runtimeObserver = observer
555+ rc := d.attachCoordinator(sess, nil, ac, true)
556+ lease := rc.attachmentLease(ac)
557+ epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 100, Rows: 30}, ac, lease)
558+ tb := sess.activeTab()
559+ p := tb.focusedPane()
560+
561+ result := make(chan bool, 1)
562+ go func() { result <- d.runResizeTransaction(sess, ac, lease, epoch) }()
563+ awaitTestCompletion(t, observer.entered, "resize commit observer did not block")
564+ for _, lock := range []struct {
565+ name string
566+ mu *sync.Mutex
567+ }{
568+ {name: "attachment send", mu: &ac.sendMu},
569+ {name: "session resize", mu: &sess.layoutApplyMu},
570+ {name: "tab resize", mu: &tb.layoutApplyMu},
571+ {name: "pane resize", mu: &p.resizeMu},
572+ } {
573+ require.True(t, lock.mu.TryLock(), "%s fence remained held across observer callback", lock.name)
574+ lock.mu.Unlock()
575+ }
576+ close(observer.release)
577+ require.True(t, awaitTestValue(t, result, "resize did not finish after observer release"))
578+}
579+
580+func TestResizeOwnerPostEffectsReleaseFencesBeforeTimerAndRenderCallbacks(t *testing.T) {
581+ d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
582+ tb := sess.activeTab()
583+ p := tb.focusedPane()
584+ clock := &resizeFenceCheckingClock{locks: []*sync.Mutex{&sess.layoutApplyMu, &tb.layoutApplyMu, &p.resizeMu}}
585+ clock.locksFree.Store(true)
586+ d.clock = clock
587+ rc := d.attachCoordinator(sess, nil, ac, true)
588+ callbackLocksFree := atomic.Bool{}
589+ callbackLocksFree.Store(true)
590+ rc.opts.onInvalidate = func(renderInvalidation) {
591+ clock.check()
592+ callbackLocksFree.Store(clock.locksFree.Load())
593+ }
594+ lease := rc.attachmentLease(ac)
595+ epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 100, Rows: 30}, ac, lease)
596+
597+ require.True(t, d.runResizeTransaction(sess, ac, lease, epoch))
598+ require.Positive(t, clock.calls.Load(), "clock boundary was not exercised")
599+ require.True(t, clock.locksFree.Load(), "timer or clock work ran under a resize ownership fence")
600+ require.True(t, callbackLocksFree.Load(), "render callback ran under a resize ownership fence")
601+}
602+
603+func TestResizeOwnerMoveBeforeClientGeometryAndCommitTelemetryDropsStaleSource(t *testing.T) {
604+ first := &transactionalResizePTY{}
605+ second := &transactionalResizePTY{}
606+ d, source, ac, _ := newManualSessionWithPTYs(t, first, second)
607+ observer := &daemonRuntimeObserver{}
608+ d.runtimeObserver = observer
609+ rc := d.attachCoordinator(source, nil, ac, true)
610+ lease := rc.attachmentLease(ac)
611+ target := domain.Size{Cols: 100, Rows: 30}
612+ epoch := rc.recordResizeRequestForLease(target, ac, lease)
613+ require.NotZero(t, epoch)
614+ movedTab := source.tabs[1]
615+ movedPane := movedTab.focusedPane()
616+ destination := &session{id: "destination", name: "destination", ctx: source.ctx, tabs: []*tab{movedTab}}
617+
618+ entered, release := pauseResizeOwnerPostEffect(d, resizeOwnerPostCommitPublication)
619+ result := make(chan bool, 1)
620+ go func() { result <- d.runResizeTransaction(source, ac, lease, epoch) }()
621+ select {
622+ case <-entered:
623+ publishResizeOwnerMoveUnderFence(t, source, destination, movedTab, movedPane)
624+ release()
625+ case accepted := <-result:
626+ ac.sendMu.Lock()
627+ publishedSize := ac.size
628+ ac.sendMu.Unlock()
629+ t.Fatalf("resize completed before geometry/telemetry owner fence: accepted=%v size=%+v marks=%+v", accepted, publishedSize, observer.marks)
630+ case <-time.After(time.Second):
631+ t.Fatal("resize neither reached geometry/telemetry publication nor completed")
632+ }
633+
634+ require.False(t, awaitTestValue(t, result, "stale source geometry publication did not return"))
635+ ac.sendMu.Lock()
636+ require.Equal(t, domain.Size{Cols: 80, Rows: 24}, ac.size, "stale source published attachment geometry after ownership commit")
637+ ac.sendMu.Unlock()
638+ for _, mark := range observer.marks {
639+ require.NotEqual(t, ports.RuntimeResizeCommitted, mark.Kind, "stale source published resize-commit telemetry after ownership commit")
640+ }
641+
642+ // The destination's finalizer is the only accepted geometry/telemetry owner.
643+ d.beforeResizeOwnerPostEffect = nil
644+ destinationSize := domain.Size{Cols: 90, Rows: 28}
645+ require.True(t, d.requestTransactionalResizeForLease(destination, nil, nil, destinationSize, true))
646+ movedPane.mu.Lock()
647+ require.Equal(t, domain.Size{Cols: 90, Rows: 26}, domain.Size{Cols: movedPane.screen.Frame.Width, Rows: movedPane.screen.Frame.Height})
648+ movedPane.mu.Unlock()
649+ commits := 0
650+ for _, mark := range observer.marks {
651+ if mark.Kind == ports.RuntimeResizeCommitted {
652+ commits++
653+ }
654+ }
655+ require.Equal(t, 1, commits, "only the destination finalizer may publish resize-commit telemetry")
656+}
657+
658+func TestSameOwnerResizePublishesClientGeometryAndCommitTelemetryOnce(t *testing.T) {
659+ d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}, &transactionalResizePTY{})
660+ observer := &daemonRuntimeObserver{}
661+ d.runtimeObserver = observer
662+ rc := d.attachCoordinator(sess, nil, ac, true)
663+ lease := rc.attachmentLease(ac)
664+ target := domain.Size{Cols: 100, Rows: 30}
665+ epoch := rc.recordResizeRequestForLease(target, ac, lease)
666+
667+ require.True(t, d.runResizeTransaction(sess, ac, lease, epoch))
668+ ac.sendMu.Lock()
669+ require.Equal(t, target, ac.size)
670+ ac.sendMu.Unlock()
671+ commits := 0
672+ for _, mark := range observer.marks {
673+ if mark.Kind == ports.RuntimeResizeCommitted {
674+ commits++
675+ }
676+ }
677+ require.Equal(t, 1, commits)
678+}
679+
680+func TestResizeOwnerMoveImmediatelyBeforeRenderInvalidationDropsStaleSource(t *testing.T) {
681+ d, source, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
682+ rc := d.attachCoordinator(source, nil, ac, true)
683+ lease := rc.attachmentLease(ac)
684+ epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 100, Rows: 30}, ac, lease)
685+ var invalidations atomic.Int32
686+ rc.opts.onInvalidate = func(renderInvalidation) { invalidations.Add(1) }
687+ tb := source.activeTab()
688+ p := tb.focusedPane()
689+ destination := &session{id: "destination", name: "destination", ctx: source.ctx, tabs: []*tab{tb}}
690+ entered, release := pauseResizeOwnerPostEffect(d, resizeOwnerPostRenderInvalidation)
691+
692+ result := make(chan bool, 1)
693+ go func() { result <- d.runResizeTransaction(source, ac, lease, epoch) }()
694+ awaitTestCompletion(t, entered, "resize did not pause before render invalidation")
695+ publishResizeOwnerMoveUnderFence(t, source, destination, tb, p)
696+ release()
697+
698+ require.False(t, awaitTestValue(t, result, "stale source invalidation did not return"))
699+ require.Zero(t, invalidations.Load(), "stale source invalidated its renderer after the move won the fence")
700+}
701+
702+func TestRetryOwnerAcceptedTabStopsWhenFailedPaneMoves(t *testing.T) {
703+ resizeErr := errors.New("scripted resize failure")
704+ pty := &transactionalResizePTY{errs: []error{resizeErr}}
705+ d, source, _, _ := newManualSessionWithPTYs(t, pty)
706+ clock := &layoutRetryClock{timers: make(chan *layoutRetryTimer, 8)}
707+ d.clock = clock
708+ tb := source.activeTab()
709+ p := tb.focusedPane()
710+ tb.mu.Lock()
711+ tb.size = domain.Size{Cols: 100, Rows: 20}
712+ tb.bumpLayoutGenerationLocked()
713+ tb.mu.Unlock()
714+
715+ require.True(t, d.applyTabLayout(source, tb))
716+ var timer *layoutRetryTimer
717+ for timer == nil {
718+ candidate := awaitTestValue(t, clock.timers, "accepted failure did not schedule its retry")
719+ if candidate.delay == minOutputRenderDeadline {
720+ timer = candidate
721+ }
722+ }
723+ source.snapshotMu.Lock()
724+ sourceGeneration := source.snapshotGeneration
725+ source.snapshotMu.Unlock()
726+ notices := len(d.notices.history())
727+
728+ destination := &session{id: "destination", name: "destination", ctx: source.ctx, tabs: []*tab{tb}}
729+ publishPaneOwner(p, destination, tb, 0)
730+ timer.fire()
731+
732+ require.Eventually(t, func() bool {
733+ tb.layoutRetryMu.Lock()
734+ defer tb.layoutRetryMu.Unlock()
735+ return !tb.layoutRetryRunning
736+ }, time.Second, time.Millisecond, "stale source retry did not retire")
737+ require.Equal(t, []domain.Size{{Cols: 100, Rows: 20}}, pty.requested(), "stale source retry reached the moved pane PTY")
738+ source.snapshotMu.Lock()
739+ require.Equal(t, sourceGeneration, source.snapshotGeneration, "stale source retry dirtied its former session")
740+ source.snapshotMu.Unlock()
741+ require.Len(t, d.notices.history(), notices, "stale source retry published a notice")
742+}
743+
744+func TestTransactionalResizeOwnerMoveAfterExternalApplyPublishesOnlyDestinationGeometry(t *testing.T) {
745+ entered := make(chan struct{})
746+ release := make(chan struct{})
747+ pty := &transactionalResizePTY{}
748+ pty.onResize = func() {
749+ if len(pty.requested()) != 1 {
750+ return
751+ }
752+ close(entered)
753+ <-release
754+ }
755+ d, source, ac, _ := newManualSessionWithPTYs(t, pty)
756+ observer := &daemonRuntimeObserver{}
757+ d.runtimeObserver = observer
758+ rc := d.attachCoordinator(source, nil, ac, true)
759+ var invalidations atomic.Int32
760+ rc.opts.onInvalidate = func(renderInvalidation) { invalidations.Add(1) }
761+ lease := rc.attachmentLease(ac)
762+ epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 100, Rows: 30}, ac, lease)
763+ require.NotZero(t, epoch)
764+ tb := source.activeTab()
765+ p := tb.focusedPane()
766+ destination := &session{id: "destination", name: "destination", ctx: source.ctx, tabs: []*tab{tb}}
767+
768+ result := make(chan bool, 1)
769+ go func() {
770+ result <- d.runResizeTransaction(source, ac, lease, epoch)
771+ }()
772+ awaitTestCompletion(t, entered, "source resize did not reach the external PTY")
773+
774+ source.mu.Lock()
775+ source.tabs = nil
776+ source.active = -1
777+ source.mu.Unlock()
778+ publishPaneOwner(p, destination, tb, 0)
779+ close(release)
780+
781+ require.False(t, awaitTestValue(t, result, "source resize did not finish after the owner move"))
782+ require.False(t, source.snapDirty.Load(), "retired source owner dirtied its snapshot")
783+ require.Zero(t, invalidations.Load(), "retired source owner invalidated its renderer")
784+ require.Empty(t, d.notices.history(), "retired source owner published a resize notice")
785+ for _, mark := range observer.marks {
786+ require.NotEqual(t, ports.RuntimeResizeCommitted, mark.Kind, "retired source owner published resize telemetry")
787+ }
788+ ac.sendMu.Lock()
789+ require.Equal(t, domain.Size{Cols: 80, Rows: 24}, ac.size, "retired source owner published client geometry")
790+ ac.sendMu.Unlock()
791+ require.Equal(t, domain.Size{Cols: 80, Rows: 23}, tb.size, "retired source owner published the moved tab size")
792+ p.mu.Lock()
793+ require.Equal(t, domain.Rect{Width: 80, Height: 23}, p.rect, "retired source owner published pane geometry")
794+ require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}, "retired source owner published VT geometry")
795+ require.True(t, p.resizeRetry, "the destination owner needs metadata for the externally applied stale size")
796+ p.mu.Unlock()
797+
798+ require.True(t, d.applyTabLayout(destination, tb))
799+ require.Equal(t, []domain.Size{{Cols: 100, Rows: 28}, {Cols: 80, Rows: 23}}, pty.requested(), "destination layout must restore its owned geometry")
800+ p.mu.Lock()
801+ require.False(t, p.resizeRetry)
802+ require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height})
803+ p.mu.Unlock()
804+}
805+
409806 func TestTransactionalResizeRejectsNewerEpochBeforeSessionPublication(t *testing.T) {
410807 first, second := &transactionalResizePTY{}, &transactionalResizePTY{}
411808 d, sess, ac, _ := newManualSessionWithPTYs(t, first, second)
@@ -505,6 +902,45 @@ func TestHeadlessResizeRetriesInvalidatedPlan(t *testing.T) {
505902 tb.mu.Unlock()
506903 }
507904
905+func TestRetryOwnerCannotPublishFloatingGeometryAfterMove(t *testing.T) {
906+ popupPTY := &transactionalResizePTY{errs: []error{errors.New("initial popup failure")}}
907+ d, source, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
908+ tb := source.activeTab()
909+ popup := newPane("popup", popupPTY, domain.Size{Cols: 80, Rows: 23})
910+ tb.mu.Lock()
911+ tb.floating = floatingSlot{state: floatingVisible, pane: popup, generation: 7}
912+ tb.mu.Unlock()
913+ owner := publishPaneOwner(popup, source, tb, 7)
914+
915+ rc := d.attachCoordinator(source, nil, ac, true)
916+ lease := rc.attachmentLease(ac)
917+ epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease)
918+ require.True(t, d.runResizeTransaction(source, ac, lease, epoch))
919+
920+ destination := &session{id: "destination", name: "destination", ctx: source.ctx, tabs: []*tab{tb}}
921+ publishPaneOwner(popup, destination, tb, 7)
922+ destinationRect := domain.Rect{X: 9, Y: 4, Width: 31, Height: 9}
923+ popup.mu.Lock()
924+ popup.rect = destinationRect
925+ popup.popupGeometry = floatingGeometry{Inner: destinationRect}
926+ popup.mu.Unlock()
927+ source.snapshotMu.Lock()
928+ sourceGeneration := source.snapshotGeneration
929+ source.snapshotMu.Unlock()
930+ requested := popupPTY.requested()
931+
932+ d.retryResizeMembers(source, ac, lease, epoch, []resizeMember{{session: source, tab: tb, pane: popup, owner: owner, isFloating: true, floatingGeneration: 7}})
933+
934+ require.Equal(t, requested, popupPTY.requested(), "stale floating retry reached the moved PTY")
935+ popup.mu.Lock()
936+ require.Equal(t, destinationRect, popup.rect, "stale floating retry published source geometry")
937+ require.Equal(t, destinationRect, popup.popupGeometry.Inner, "stale floating retry published source popup geometry")
938+ popup.mu.Unlock()
939+ source.snapshotMu.Lock()
940+ require.Equal(t, sourceGeneration, source.snapshotGeneration, "stale floating retry dirtied its former session")
941+ source.snapshotMu.Unlock()
942+}
943+
508944 func TestTransactionalResizeRetriesAcceptedFloatingSlotByIdentity(t *testing.T) {
509945 popupPTY := &transactionalResizePTY{errs: []error{errors.New("first popup resize fails")}}
510946 d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
@@ -5,6 +5,7 @@ import (
5 "io"5 "io"
6 "strings"6 "strings"
7 "sync"7 "sync"
8+ "sync/atomic"
8 "testing"9 "testing"
9 "time"10 "time"
10 11
@@ -271,10 +272,10 @@ func TestApplySessionLayoutReportsResizeFailuresOnce(t *testing.T) {
271 }272 }
272 d, sess, _, _ := newManualSessionWithPTYs(t, first, second)273 d, sess, _, _ := newManualSessionWithPTYs(t, first, second)
273 274
274- failed, ok := d.applySessionLayout(sess, domain.Size{Cols: 100, Rows: 30}, nil, nil)275+ result, ok := d.applySessionLayout(sess, domain.Size{Cols: 100, Rows: 30}, nil, nil)
275 276
276 require.Equal(t, tc.wantOK, ok)277 require.Equal(t, tc.wantOK, ok)
277- require.Len(t, failed, tc.wantFailed)278+ require.Len(t, result.failed, tc.wantFailed)
278 count := 0279 count := 0
279 for _, notification := range d.notices.history() {280 for _, notification := range d.notices.history() {
280 if notification.Code == domain.NoticeResizeFailed {281 if notification.Code == domain.NoticeResizeFailed {
@@ -406,6 +407,402 @@ func TestTransactionalResizeEpochLifecycleAndRetryContract(t *testing.T) {
406 // stopped. Only the newest prepared epoch may apply, commit, and emit one full407 // stopped. Only the newest prepared epoch may apply, commit, and emit one full
407 // frame. coordinatorMockClock is a generated MockClock/MockTimer harness; its408 // frame. coordinatorMockClock is a generated MockClock/MockTimer harness; its
408 // channels make callback order explicit and do not use wall-clock waits.409 // channels make callback order explicit and do not use wall-clock waits.
410+func publishResizeOwnerMoveUnderFence(t *testing.T, source, destination *session, tb *tab, p *pane) {
411+ t.Helper()
412+ fences := newMovePaneResizeFences(source, destination, tb, tb, p)
413+ require.True(t, fences.acquire(func() bool {
414+ p.mu.Lock()
415+ p.publishOwnerLocked(destination, tb, 0)
416+ p.mu.Unlock()
417+ return true
418+ }))
419+ fences.Release()
420+}
421+
422+func pauseResizeOwnerPostEffect(d *Daemon, effect resizeOwnerPostEffect) (<-chan struct{}, func()) {
423+ entered := make(chan struct{})
424+ release := make(chan struct{})
425+ var once sync.Once
426+ d.beforeResizeOwnerPostEffect = func(got resizeOwnerPostEffect) {
427+ if got != effect {
428+ return
429+ }
430+ once.Do(func() { close(entered) })
431+ <-release
432+ }
433+ return entered, func() { close(release) }
434+}
435+
436+func TestResizeOwnerMoveImmediatelyBeforeSnapshotDirtyDropsStaleSource(t *testing.T) {
437+ d, source, _, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
438+ source.snapEligible.Store(true)
439+ tb := source.activeTab()
440+ p := tb.focusedPane()
441+ tb.mu.Lock()
442+ tb.size = domain.Size{Cols: 100, Rows: 20}
443+ tb.bumpLayoutGenerationLocked()
444+ tb.mu.Unlock()
445+ destination := &session{id: "destination", name: "destination", ctx: source.ctx, tabs: []*tab{tb}}
446+ entered, release := pauseResizeOwnerPostEffect(d, resizeOwnerPostSnapshotDirty)
447+
448+ result := make(chan bool, 1)
449+ go func() { result <- d.applyTabLayout(source, tb) }()
450+ awaitTestCompletion(t, entered, "resize did not pause before snapshot dirtiness")
451+ publishResizeOwnerMoveUnderFence(t, source, destination, tb, p)
452+ release()
453+
454+ require.False(t, awaitTestValue(t, result, "stale source layout did not return"))
455+ require.False(t, source.snapDirty.Load(), "stale source dirtied its snapshot after the move won the fence")
456+}
457+
458+func TestRetryOwnerMoveImmediatelyBeforeScheduleContinuesAtDestination(t *testing.T) {
459+ resizeErr := errors.New("scripted resize failure")
460+ pty := &transactionalResizePTY{errs: []error{resizeErr, resizeErr}}
461+ d, source, _, _ := newManualSessionWithPTYs(t, pty)
462+ clock := &layoutRetryClock{timers: make(chan *layoutRetryTimer, 8)}
463+ d.clock = clock
464+ tb := source.activeTab()
465+ p := tb.focusedPane()
466+ tb.mu.Lock()
467+ tb.size = domain.Size{Cols: 100, Rows: 20}
468+ tb.bumpLayoutGenerationLocked()
469+ tb.mu.Unlock()
470+ destination := &session{id: "destination", name: "destination", ctx: source.ctx, tabs: []*tab{tb}}
471+ entered, release := pauseResizeOwnerPostEffect(d, resizeOwnerPostRetrySchedule)
472+
473+ result := make(chan bool, 1)
474+ go func() { result <- d.applyTabLayout(source, tb) }()
475+ awaitTestCompletion(t, entered, "resize did not pause before retry scheduling")
476+ publishResizeOwnerMoveUnderFence(t, source, destination, tb, p)
477+ release()
478+
479+ require.False(t, awaitTestValue(t, result, "stale source retry publication did not return"))
480+ tb.layoutRetryMu.Lock()
481+ require.False(t, tb.layoutRetryRunning, "stale source published a retry worker")
482+ tb.layoutRetryMu.Unlock()
483+ for {
484+ select {
485+ case timer := <-clock.timers:
486+ require.NotEqual(t, minOutputRenderDeadline, timer.delay, "stale source armed a retry timer")
487+ default:
488+ goto timersDrained
489+ }
490+ }
491+
492+timersDrained:
493+ d.beforeResizeOwnerPostEffect = nil
494+ require.True(t, d.applyTabLayout(destination, tb), "destination must recreate retry from pane metadata")
495+ for {
496+ timer := awaitTestValue(t, clock.timers, "destination did not continue the moved retry")
497+ if timer.delay == minOutputRenderDeadline {
498+ break
499+ }
500+ }
501+}
502+
503+type resizeFenceCheckingClock struct {
504+ locks []*sync.Mutex
505+ calls atomic.Int32
506+ locksFree atomic.Bool
507+}
508+
509+func (c *resizeFenceCheckingClock) check() {
510+ c.calls.Add(1)
511+ locked := make([]*sync.Mutex, 0, len(c.locks))
512+ for _, lock := range c.locks {
513+ if !lock.TryLock() {
514+ for i := len(locked) - 1; i >= 0; i-- {
515+ locked[i].Unlock()
516+ }
517+ c.locksFree.Store(false)
518+ return
519+ }
520+ locked = append(locked, lock)
521+ }
522+ for i := len(locked) - 1; i >= 0; i-- {
523+ locked[i].Unlock()
524+ }
525+}
526+
527+func (c *resizeFenceCheckingClock) Now() time.Time {
528+ c.check()
529+ return time.Time{}
530+}
531+
532+func (c *resizeFenceCheckingClock) NewTimer(time.Duration) ports.Timer {
533+ c.check()
534+ return stubTimer{}
535+}
536+
537+type blockingResizeCommitObserver struct {
538+ entered chan struct{}
539+ release chan struct{}
540+ once sync.Once
541+}
542+
543+func (o *blockingResizeCommitObserver) ObserveRuntime(mark ports.RuntimeMark) {
544+ if mark.Kind != ports.RuntimeResizeCommitted {
545+ return
546+ }
547+ o.once.Do(func() { close(o.entered) })
548+ <-o.release
549+}
550+
551+func TestResizeCommitObserverBlocksWithoutHoldingOwnerOrAttachmentFences(t *testing.T) {
552+ d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
553+ observer := &blockingResizeCommitObserver{entered: make(chan struct{}), release: make(chan struct{})}
554+ d.runtimeObserver = observer
555+ rc := d.attachCoordinator(sess, nil, ac, true)
556+ lease := rc.attachmentLease(ac)
557+ epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 100, Rows: 30}, ac, lease)
558+ tb := sess.activeTab()
559+ p := tb.focusedPane()
560+
561+ result := make(chan bool, 1)
562+ go func() { result <- d.runResizeTransaction(sess, ac, lease, epoch) }()
563+ awaitTestCompletion(t, observer.entered, "resize commit observer did not block")
564+ for _, lock := range []struct {
565+ name string
566+ mu *sync.Mutex
567+ }{
568+ {name: "attachment send", mu: &ac.sendMu},
569+ {name: "session resize", mu: &sess.layoutApplyMu},
570+ {name: "tab resize", mu: &tb.layoutApplyMu},
571+ {name: "pane resize", mu: &p.resizeMu},
572+ } {
573+ require.True(t, lock.mu.TryLock(), "%s fence remained held across observer callback", lock.name)
574+ lock.mu.Unlock()
575+ }
576+ close(observer.release)
577+ require.True(t, awaitTestValue(t, result, "resize did not finish after observer release"))
578+}
579+
580+func TestResizeOwnerPostEffectsReleaseFencesBeforeTimerAndRenderCallbacks(t *testing.T) {
581+ d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
582+ tb := sess.activeTab()
583+ p := tb.focusedPane()
584+ clock := &resizeFenceCheckingClock{locks: []*sync.Mutex{&sess.layoutApplyMu, &tb.layoutApplyMu, &p.resizeMu}}
585+ clock.locksFree.Store(true)
586+ d.clock = clock
587+ rc := d.attachCoordinator(sess, nil, ac, true)
588+ callbackLocksFree := atomic.Bool{}
589+ callbackLocksFree.Store(true)
590+ rc.opts.onInvalidate = func(renderInvalidation) {
591+ clock.check()
592+ callbackLocksFree.Store(clock.locksFree.Load())
593+ }
594+ lease := rc.attachmentLease(ac)
595+ epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 100, Rows: 30}, ac, lease)
596+
597+ require.True(t, d.runResizeTransaction(sess, ac, lease, epoch))
598+ require.Positive(t, clock.calls.Load(), "clock boundary was not exercised")
599+ require.True(t, clock.locksFree.Load(), "timer or clock work ran under a resize ownership fence")
600+ require.True(t, callbackLocksFree.Load(), "render callback ran under a resize ownership fence")
601+}
602+
603+func TestResizeOwnerMoveBeforeClientGeometryAndCommitTelemetryDropsStaleSource(t *testing.T) {
604+ first := &transactionalResizePTY{}
605+ second := &transactionalResizePTY{}
606+ d, source, ac, _ := newManualSessionWithPTYs(t, first, second)
607+ observer := &daemonRuntimeObserver{}
608+ d.runtimeObserver = observer
609+ rc := d.attachCoordinator(source, nil, ac, true)
610+ lease := rc.attachmentLease(ac)
611+ target := domain.Size{Cols: 100, Rows: 30}
612+ epoch := rc.recordResizeRequestForLease(target, ac, lease)
613+ require.NotZero(t, epoch)
614+ movedTab := source.tabs[1]
615+ movedPane := movedTab.focusedPane()
616+ destination := &session{id: "destination", name: "destination", ctx: source.ctx, tabs: []*tab{movedTab}}
617+
618+ entered, release := pauseResizeOwnerPostEffect(d, resizeOwnerPostCommitPublication)
619+ result := make(chan bool, 1)
620+ go func() { result <- d.runResizeTransaction(source, ac, lease, epoch) }()
621+ select {
622+ case <-entered:
623+ publishResizeOwnerMoveUnderFence(t, source, destination, movedTab, movedPane)
624+ release()
625+ case accepted := <-result:
626+ ac.sendMu.Lock()
627+ publishedSize := ac.size
628+ ac.sendMu.Unlock()
629+ t.Fatalf("resize completed before geometry/telemetry owner fence: accepted=%v size=%+v marks=%+v", accepted, publishedSize, observer.marks)
630+ case <-time.After(time.Second):
631+ t.Fatal("resize neither reached geometry/telemetry publication nor completed")
632+ }
633+
634+ require.False(t, awaitTestValue(t, result, "stale source geometry publication did not return"))
635+ ac.sendMu.Lock()
636+ require.Equal(t, domain.Size{Cols: 80, Rows: 24}, ac.size, "stale source published attachment geometry after ownership commit")
637+ ac.sendMu.Unlock()
638+ for _, mark := range observer.marks {
639+ require.NotEqual(t, ports.RuntimeResizeCommitted, mark.Kind, "stale source published resize-commit telemetry after ownership commit")
640+ }
641+
642+ // The destination's finalizer is the only accepted geometry/telemetry owner.
643+ d.beforeResizeOwnerPostEffect = nil
644+ destinationSize := domain.Size{Cols: 90, Rows: 28}
645+ require.True(t, d.requestTransactionalResizeForLease(destination, nil, nil, destinationSize, true))
646+ movedPane.mu.Lock()
647+ require.Equal(t, domain.Size{Cols: 90, Rows: 26}, domain.Size{Cols: movedPane.screen.Frame.Width, Rows: movedPane.screen.Frame.Height})
648+ movedPane.mu.Unlock()
649+ commits := 0
650+ for _, mark := range observer.marks {
651+ if mark.Kind == ports.RuntimeResizeCommitted {
652+ commits++
653+ }
654+ }
655+ require.Equal(t, 1, commits, "only the destination finalizer may publish resize-commit telemetry")
656+}
657+
658+func TestSameOwnerResizePublishesClientGeometryAndCommitTelemetryOnce(t *testing.T) {
659+ d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}, &transactionalResizePTY{})
660+ observer := &daemonRuntimeObserver{}
661+ d.runtimeObserver = observer
662+ rc := d.attachCoordinator(sess, nil, ac, true)
663+ lease := rc.attachmentLease(ac)
664+ target := domain.Size{Cols: 100, Rows: 30}
665+ epoch := rc.recordResizeRequestForLease(target, ac, lease)
666+
667+ require.True(t, d.runResizeTransaction(sess, ac, lease, epoch))
668+ ac.sendMu.Lock()
669+ require.Equal(t, target, ac.size)
670+ ac.sendMu.Unlock()
671+ commits := 0
672+ for _, mark := range observer.marks {
673+ if mark.Kind == ports.RuntimeResizeCommitted {
674+ commits++
675+ }
676+ }
677+ require.Equal(t, 1, commits)
678+}
679+
680+func TestResizeOwnerMoveImmediatelyBeforeRenderInvalidationDropsStaleSource(t *testing.T) {
681+ d, source, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
682+ rc := d.attachCoordinator(source, nil, ac, true)
683+ lease := rc.attachmentLease(ac)
684+ epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 100, Rows: 30}, ac, lease)
685+ var invalidations atomic.Int32
686+ rc.opts.onInvalidate = func(renderInvalidation) { invalidations.Add(1) }
687+ tb := source.activeTab()
688+ p := tb.focusedPane()
689+ destination := &session{id: "destination", name: "destination", ctx: source.ctx, tabs: []*tab{tb}}
690+ entered, release := pauseResizeOwnerPostEffect(d, resizeOwnerPostRenderInvalidation)
691+
692+ result := make(chan bool, 1)
693+ go func() { result <- d.runResizeTransaction(source, ac, lease, epoch) }()
694+ awaitTestCompletion(t, entered, "resize did not pause before render invalidation")
695+ publishResizeOwnerMoveUnderFence(t, source, destination, tb, p)
696+ release()
697+
698+ require.False(t, awaitTestValue(t, result, "stale source invalidation did not return"))
699+ require.Zero(t, invalidations.Load(), "stale source invalidated its renderer after the move won the fence")
700+}
701+
702+func TestRetryOwnerAcceptedTabStopsWhenFailedPaneMoves(t *testing.T) {
703+ resizeErr := errors.New("scripted resize failure")
704+ pty := &transactionalResizePTY{errs: []error{resizeErr}}
705+ d, source, _, _ := newManualSessionWithPTYs(t, pty)
706+ clock := &layoutRetryClock{timers: make(chan *layoutRetryTimer, 8)}
707+ d.clock = clock
708+ tb := source.activeTab()
709+ p := tb.focusedPane()
710+ tb.mu.Lock()
711+ tb.size = domain.Size{Cols: 100, Rows: 20}
712+ tb.bumpLayoutGenerationLocked()
713+ tb.mu.Unlock()
714+
715+ require.True(t, d.applyTabLayout(source, tb))
716+ var timer *layoutRetryTimer
717+ for timer == nil {
718+ candidate := awaitTestValue(t, clock.timers, "accepted failure did not schedule its retry")
719+ if candidate.delay == minOutputRenderDeadline {
720+ timer = candidate
721+ }
722+ }
723+ source.snapshotMu.Lock()
724+ sourceGeneration := source.snapshotGeneration
725+ source.snapshotMu.Unlock()
726+ notices := len(d.notices.history())
727+
728+ destination := &session{id: "destination", name: "destination", ctx: source.ctx, tabs: []*tab{tb}}
729+ publishPaneOwner(p, destination, tb, 0)
730+ timer.fire()
731+
732+ require.Eventually(t, func() bool {
733+ tb.layoutRetryMu.Lock()
734+ defer tb.layoutRetryMu.Unlock()
735+ return !tb.layoutRetryRunning
736+ }, time.Second, time.Millisecond, "stale source retry did not retire")
737+ require.Equal(t, []domain.Size{{Cols: 100, Rows: 20}}, pty.requested(), "stale source retry reached the moved pane PTY")
738+ source.snapshotMu.Lock()
739+ require.Equal(t, sourceGeneration, source.snapshotGeneration, "stale source retry dirtied its former session")
740+ source.snapshotMu.Unlock()
741+ require.Len(t, d.notices.history(), notices, "stale source retry published a notice")
742+}
743+
744+func TestTransactionalResizeOwnerMoveAfterExternalApplyPublishesOnlyDestinationGeometry(t *testing.T) {
745+ entered := make(chan struct{})
746+ release := make(chan struct{})
747+ pty := &transactionalResizePTY{}
748+ pty.onResize = func() {
749+ if len(pty.requested()) != 1 {
750+ return
751+ }
752+ close(entered)
753+ <-release
754+ }
755+ d, source, ac, _ := newManualSessionWithPTYs(t, pty)
756+ observer := &daemonRuntimeObserver{}
757+ d.runtimeObserver = observer
758+ rc := d.attachCoordinator(source, nil, ac, true)
759+ var invalidations atomic.Int32
760+ rc.opts.onInvalidate = func(renderInvalidation) { invalidations.Add(1) }
761+ lease := rc.attachmentLease(ac)
762+ epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 100, Rows: 30}, ac, lease)
763+ require.NotZero(t, epoch)
764+ tb := source.activeTab()
765+ p := tb.focusedPane()
766+ destination := &session{id: "destination", name: "destination", ctx: source.ctx, tabs: []*tab{tb}}
767+
768+ result := make(chan bool, 1)
769+ go func() {
770+ result <- d.runResizeTransaction(source, ac, lease, epoch)
771+ }()
772+ awaitTestCompletion(t, entered, "source resize did not reach the external PTY")
773+
774+ source.mu.Lock()
775+ source.tabs = nil
776+ source.active = -1
777+ source.mu.Unlock()
778+ publishPaneOwner(p, destination, tb, 0)
779+ close(release)
780+
781+ require.False(t, awaitTestValue(t, result, "source resize did not finish after the owner move"))
782+ require.False(t, source.snapDirty.Load(), "retired source owner dirtied its snapshot")
783+ require.Zero(t, invalidations.Load(), "retired source owner invalidated its renderer")
784+ require.Empty(t, d.notices.history(), "retired source owner published a resize notice")
785+ for _, mark := range observer.marks {
786+ require.NotEqual(t, ports.RuntimeResizeCommitted, mark.Kind, "retired source owner published resize telemetry")
787+ }
788+ ac.sendMu.Lock()
789+ require.Equal(t, domain.Size{Cols: 80, Rows: 24}, ac.size, "retired source owner published client geometry")
790+ ac.sendMu.Unlock()
791+ require.Equal(t, domain.Size{Cols: 80, Rows: 23}, tb.size, "retired source owner published the moved tab size")
792+ p.mu.Lock()
793+ require.Equal(t, domain.Rect{Width: 80, Height: 23}, p.rect, "retired source owner published pane geometry")
794+ require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}, "retired source owner published VT geometry")
795+ require.True(t, p.resizeRetry, "the destination owner needs metadata for the externally applied stale size")
796+ p.mu.Unlock()
797+
798+ require.True(t, d.applyTabLayout(destination, tb))
799+ require.Equal(t, []domain.Size{{Cols: 100, Rows: 28}, {Cols: 80, Rows: 23}}, pty.requested(), "destination layout must restore its owned geometry")
800+ p.mu.Lock()
801+ require.False(t, p.resizeRetry)
802+ require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height})
803+ p.mu.Unlock()
804+}
805+
409 func TestTransactionalResizeRejectsNewerEpochBeforeSessionPublication(t *testing.T) {806 func TestTransactionalResizeRejectsNewerEpochBeforeSessionPublication(t *testing.T) {
410 first, second := &transactionalResizePTY{}, &transactionalResizePTY{}807 first, second := &transactionalResizePTY{}, &transactionalResizePTY{}
411 d, sess, ac, _ := newManualSessionWithPTYs(t, first, second)808 d, sess, ac, _ := newManualSessionWithPTYs(t, first, second)
@@ -505,6 +902,45 @@ func TestHeadlessResizeRetriesInvalidatedPlan(t *testing.T) {
505 tb.mu.Unlock()902 tb.mu.Unlock()
506 }903 }
507 904
905+func TestRetryOwnerCannotPublishFloatingGeometryAfterMove(t *testing.T) {
906+ popupPTY := &transactionalResizePTY{errs: []error{errors.New("initial popup failure")}}
907+ d, source, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
908+ tb := source.activeTab()
909+ popup := newPane("popup", popupPTY, domain.Size{Cols: 80, Rows: 23})
910+ tb.mu.Lock()
911+ tb.floating = floatingSlot{state: floatingVisible, pane: popup, generation: 7}
912+ tb.mu.Unlock()
913+ owner := publishPaneOwner(popup, source, tb, 7)
914+
915+ rc := d.attachCoordinator(source, nil, ac, true)
916+ lease := rc.attachmentLease(ac)
917+ epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease)
918+ require.True(t, d.runResizeTransaction(source, ac, lease, epoch))
919+
920+ destination := &session{id: "destination", name: "destination", ctx: source.ctx, tabs: []*tab{tb}}
921+ publishPaneOwner(popup, destination, tb, 7)
922+ destinationRect := domain.Rect{X: 9, Y: 4, Width: 31, Height: 9}
923+ popup.mu.Lock()
924+ popup.rect = destinationRect
925+ popup.popupGeometry = floatingGeometry{Inner: destinationRect}
926+ popup.mu.Unlock()
927+ source.snapshotMu.Lock()
928+ sourceGeneration := source.snapshotGeneration
929+ source.snapshotMu.Unlock()
930+ requested := popupPTY.requested()
931+
932+ d.retryResizeMembers(source, ac, lease, epoch, []resizeMember{{session: source, tab: tb, pane: popup, owner: owner, isFloating: true, floatingGeneration: 7}})
933+
934+ require.Equal(t, requested, popupPTY.requested(), "stale floating retry reached the moved PTY")
935+ popup.mu.Lock()
936+ require.Equal(t, destinationRect, popup.rect, "stale floating retry published source geometry")
937+ require.Equal(t, destinationRect, popup.popupGeometry.Inner, "stale floating retry published source popup geometry")
938+ popup.mu.Unlock()
939+ source.snapshotMu.Lock()
940+ require.Equal(t, sourceGeneration, source.snapshotGeneration, "stale floating retry dirtied its former session")
941+ source.snapshotMu.Unlock()
942+}
943+
508 func TestTransactionalResizeRetriesAcceptedFloatingSlotByIdentity(t *testing.T) {944 func TestTransactionalResizeRetriesAcceptedFloatingSlotByIdentity(t *testing.T) {
509 popupPTY := &transactionalResizePTY{errs: []error{errors.New("first popup resize fails")}}945 popupPTY := &transactionalResizePTY{errs: []error{errors.New("first popup resize fails")}}
510 d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})946 d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})

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