detached afa2cd4

fix(review): address concurrency and lifecycle findings

commit
afa2cd49b1e4d6977d4b2442295662ae419a4daa
author
brice · · 2026-07-28 15:51 UTC
parent
dad29ba
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
55 files · +698 -501
M internal/app/cmd_test.go +2 -1
@@ -6,6 +6,7 @@ import (
66 "io"
77 "math"
88 "reflect"
9+ "strconv"
910 "strings"
1011 "testing"
1112
@@ -156,7 +157,7 @@ func TestMoveCmdInvalidArgumentResultExitsTwo(t *testing.T) {
156157 {slug: "move-tab"},
157158 {slug: "move-tab", args: []string{"work", "extra"}},
158159 } {
159- t.Run(invocation.slug+"/"+string(rune(len(invocation.args)+'0')), func(t *testing.T) {
160+ t.Run(invocation.slug+"/"+strconv.Itoa(len(invocation.args)), func(t *testing.T) {
160161 transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{
161162 Code: ports.ErrInvalidCommandArgs, Text: "invalid command arguments",
162163 })}}
@@ -6,6 +6,7 @@ import (
6 "io"6 "io"
7 "math"7 "math"
8 "reflect"8 "reflect"
9+ "strconv"
9 "strings"10 "strings"
10 "testing"11 "testing"
11 12
@@ -156,7 +157,7 @@ func TestMoveCmdInvalidArgumentResultExitsTwo(t *testing.T) {
156 {slug: "move-tab"},157 {slug: "move-tab"},
157 {slug: "move-tab", args: []string{"work", "extra"}},158 {slug: "move-tab", args: []string{"work", "extra"}},
158 } {159 } {
159- t.Run(invocation.slug+"/"+string(rune(len(invocation.args)+'0')), func(t *testing.T) {160+ t.Run(invocation.slug+"/"+strconv.Itoa(len(invocation.args)), func(t *testing.T) {
160 transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{161 transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{
161 Code: ports.ErrInvalidCommandArgs, Text: "invalid command arguments",162 Code: ports.ErrInvalidCommandArgs, Text: "invalid command arguments",
162 })}}163 })}}
M internal/usecase/daemon/attachment_transition.go +2 -2
@@ -123,11 +123,11 @@ func (d *Daemon) freezeAttachmentTransition(req attachmentTransitionRequest, par
123123 drainDeadline = newRoleEffectDrainDeadline(d.clock)
124124 drainDone = drainDeadline.Done
125125 }
126- frozen := freezeRoleEffectGatesInterruptingObservedUntil(participants.interrupts, drainDone, func(ac *attachedClient) {
126+ frozen := freezeRoleEffectGatesWith(roleEffectFreezeOptions{interrupts: participants.interrupts, done: drainDone, afterFrozen: func(ac *attachedClient) {
127127 if d.afterRoleEffectGateFrozen != nil {
128128 d.afterRoleEffectGateFrozen(req.action, ac)
129129 }
130- }, participants.clients...)
130+ }}, participants.clients...)
131131 if drainDeadline != nil {
132132 drainDeadline.stop()
133133 }
@@ -123,11 +123,11 @@ func (d *Daemon) freezeAttachmentTransition(req attachmentTransitionRequest, par
123 drainDeadline = newRoleEffectDrainDeadline(d.clock)123 drainDeadline = newRoleEffectDrainDeadline(d.clock)
124 drainDone = drainDeadline.Done124 drainDone = drainDeadline.Done
125 }125 }
126- frozen := freezeRoleEffectGatesInterruptingObservedUntil(participants.interrupts, drainDone, func(ac *attachedClient) {126+ frozen := freezeRoleEffectGatesWith(roleEffectFreezeOptions{interrupts: participants.interrupts, done: drainDone, afterFrozen: func(ac *attachedClient) {
127 if d.afterRoleEffectGateFrozen != nil {127 if d.afterRoleEffectGateFrozen != nil {
128 d.afterRoleEffectGateFrozen(req.action, ac)128 d.afterRoleEffectGateFrozen(req.action, ac)
129 }129 }
130- }, participants.clients...)130+ }}, participants.clients...)
131 if drainDeadline != nil {131 if drainDeadline != nil {
132 drainDeadline.stop()132 drainDeadline.stop()
133 }133 }
M internal/usecase/daemon/client_frame_routing.go +1 -1
@@ -105,7 +105,7 @@ func (d *Daemon) handleActiveClientFrame(token attachmentRoleToken, f ports.Fram
105105 case ports.MsgPing:
106106 if err := token.sendActiveControl(framePong()); err != nil {
107107 token.endRoleEffect()
108- d.detachOnSendError(token.sess, token.ac, token.transport.transport)
108+ d.detachOnRoleSendError(token, token.transport.transport)
109109 return true
110110 }
111111 default:
@@ -105,7 +105,7 @@ func (d *Daemon) handleActiveClientFrame(token attachmentRoleToken, f ports.Fram
105 case ports.MsgPing:105 case ports.MsgPing:
106 if err := token.sendActiveControl(framePong()); err != nil {106 if err := token.sendActiveControl(framePong()); err != nil {
107 token.endRoleEffect()107 token.endRoleEffect()
108- d.detachOnSendError(token.sess, token.ac, token.transport.transport)108+ d.detachOnRoleSendError(token, token.transport.transport)
109 return true109 return true
110 }110 }
111 default:111 default:
M internal/usecase/daemon/clipboard.go +6 -5
@@ -255,7 +255,8 @@ func (d *Daemon) boundedSendClipboardForward(item clipboardForward, ticket *role
255255 return token.transport.transport, errAttachmentTransition
256256 }
257257 expected := token.transport
258- send := func(owned bool) error {
258+ ownedTransport, owned := expected.transport.(ports.OwnedSynchronousTransport)
259+ send := func() error {
259260 ac := token.ac
260261 ac.sendMu.Lock()
261262 defer ac.sendMu.Unlock()
@@ -268,7 +269,7 @@ func (d *Daemon) boundedSendClipboardForward(item clipboardForward, ticket *role
268269 frame := ac.output.sideEffect(item.seq, ac.echoAck.Load())
269270 var err error
270271 if owned {
271- err = expected.transport.(ports.OwnedSynchronousTransport).SendSynchronous(frame)
272+ err = ownedTransport.SendSynchronous(frame)
272273 } else {
273274 err = expected.transport.Send(frame)
274275 }
@@ -278,10 +279,10 @@ func (d *Daemon) boundedSendClipboardForward(item clipboardForward, ticket *role
278279 ticket.endTransportSend()
279280 return err
280281 }
281- if _, owned := expected.transport.(ports.OwnedSynchronousTransport); owned {
282- return expected.transport, send(true)
282+ if owned {
283+ return expected.transport, send()
283284 }
284- return d.boundedSendWith(expected.transport, func() error { return send(false) })
285+ return d.boundedSendWith(expected.transport, send)
285286 }
286287
287288 // beginClipboardOwnerSend validates the source pane generation after sendMu
@@ -255,7 +255,8 @@ func (d *Daemon) boundedSendClipboardForward(item clipboardForward, ticket *role
255 return token.transport.transport, errAttachmentTransition255 return token.transport.transport, errAttachmentTransition
256 }256 }
257 expected := token.transport257 expected := token.transport
258- send := func(owned bool) error {258+ ownedTransport, owned := expected.transport.(ports.OwnedSynchronousTransport)
259+ send := func() error {
259 ac := token.ac260 ac := token.ac
260 ac.sendMu.Lock()261 ac.sendMu.Lock()
261 defer ac.sendMu.Unlock()262 defer ac.sendMu.Unlock()
@@ -268,7 +269,7 @@ func (d *Daemon) boundedSendClipboardForward(item clipboardForward, ticket *role
268 frame := ac.output.sideEffect(item.seq, ac.echoAck.Load())269 frame := ac.output.sideEffect(item.seq, ac.echoAck.Load())
269 var err error270 var err error
270 if owned {271 if owned {
271- err = expected.transport.(ports.OwnedSynchronousTransport).SendSynchronous(frame)272+ err = ownedTransport.SendSynchronous(frame)
272 } else {273 } else {
273 err = expected.transport.Send(frame)274 err = expected.transport.Send(frame)
274 }275 }
@@ -278,10 +279,10 @@ func (d *Daemon) boundedSendClipboardForward(item clipboardForward, ticket *role
278 ticket.endTransportSend()279 ticket.endTransportSend()
279 return err280 return err
280 }281 }
281- if _, owned := expected.transport.(ports.OwnedSynchronousTransport); owned {282+ if owned {
282- return expected.transport, send(true)283+ return expected.transport, send()
283 }284 }
284- return d.boundedSendWith(expected.transport, func() error { return send(false) })285+ return d.boundedSendWith(expected.transport, send)
285 }286 }
286 287
287 // beginClipboardOwnerSend validates the source pane generation after sendMu288 // beginClipboardOwnerSend validates the source pane generation after sendMu
M internal/usecase/daemon/clipboard_test.go +17 -14
@@ -209,11 +209,15 @@ func publishActiveClipboardCapability(d *Daemon, sess *session, ac *attachedClie
209209 ac.publishRoleCapability(token)
210210 }
211211
212-func clipboardOwnerLease(sess *session) paneEffectLease {
212+func clipboardOwnerLease(t *testing.T, sess *session) paneEffectLease {
213+ t.Helper()
213214 tb := sess.activeTab()
214215 tb.mu.Lock()
215216 p := tb.terminalTargetLocked()
216217 tb.mu.Unlock()
218+ if p == nil {
219+ t.Fatal("clipboard owner has no terminal target")
220+ }
217221 return p.effectLease()
218222 }
219223
@@ -348,7 +352,7 @@ func TestQueuedClipboardAfterPaneMoveDoesNotSendToFormerOwner(t *testing.T) {
348352 sess.mu.Lock()
349353 sess.clipboardWorkerRunning = true
350354 sess.mu.Unlock()
351- d.forwardClipboardAsync(clipboardOwnerLease(sess), base64.StdEncoding.EncodeToString([]byte("queued")))
355+ d.forwardClipboardAsync(clipboardOwnerLease(t, sess), base64.StdEncoding.EncodeToString([]byte("queued")))
352356
353357 sourceTab := sess.activeTab()
354358 p := sourceTab.focusedPane()
@@ -363,10 +367,11 @@ func TestQueuedClipboardAfterPaneMoveDoesNotSendToFormerOwner(t *testing.T) {
363367 }
364368
365369 type movingClipboardErrorTransport struct {
366- started chan struct{}
367- release chan struct{}
368- closed chan struct{}
369- closeOnce sync.Once
370+ started chan struct{}
371+ release chan struct{}
372+ closed chan struct{}
373+ startedOnce sync.Once
374+ closeOnce sync.Once
370375 }
371376
372377 func newMovingClipboardErrorTransport() *movingClipboardErrorTransport {
@@ -378,7 +383,7 @@ func newMovingClipboardErrorTransport() *movingClipboardErrorTransport {
378383 }
379384
380385 func (t *movingClipboardErrorTransport) Send(ports.Frame) error {
381- close(t.started)
386+ t.startedOnce.Do(func() { close(t.started) })
382387 <-t.release
383388 return errors.New("clipboard send failed after pane move")
384389 }
@@ -399,7 +404,7 @@ func TestQueuedClipboardRevalidatesOwnerAfterWaitingForClientSendLock(t *testing
399404 sess.mu.Lock()
400405 sess.clipboardWorkerRunning = true
401406 sess.mu.Unlock()
402- d.forwardClipboardAsync(clipboardOwnerLease(sess), base64.StdEncoding.EncodeToString([]byte("queued")))
407+ d.forwardClipboardAsync(clipboardOwnerLease(t, sess), base64.StdEncoding.EncodeToString([]byte("queued")))
403408
404409 ac.sendMu.Lock()
405410 sendMuLocked := true
@@ -414,9 +419,7 @@ func TestQueuedClipboardRevalidatesOwnerAfterWaitingForClientSendLock(t *testing
414419 close(workerDone)
415420 }()
416421 require.Eventually(t, func() bool {
417- ac.roleEffects.mu.Lock()
418- defer ac.roleEffects.mu.Unlock()
419- return ac.roleEffects.inFlight == 1
422+ return ac.roleEffects.inFlightCount() == 1
420423 }, time.Second, time.Millisecond, "clipboard send was not admitted before waiting on sendMu")
421424
422425 sourceTab := sess.activeTab()
@@ -441,7 +444,7 @@ func TestClipboardSendErrorAfterPaneMoveDoesNotDetachFormerOwner(t *testing.T) {
441444 sess.mu.Lock()
442445 sess.clipboardWorkerRunning = true
443446 sess.mu.Unlock()
444- d.forwardClipboardAsync(clipboardOwnerLease(sess), base64.StdEncoding.EncodeToString([]byte("queued")))
447+ d.forwardClipboardAsync(clipboardOwnerLease(t, sess), base64.StdEncoding.EncodeToString([]byte("queued")))
445448
446449 workerDone := make(chan struct{})
447450 go func() {
@@ -478,7 +481,7 @@ func TestQueuedClipboardBeforeSnatchDropsExactStaleCapability(t *testing.T) {
478481 sess.mu.Lock()
479482 sess.clipboardWorkerRunning = true
480483 sess.mu.Unlock()
481- d.forwardClipboardAsync(clipboardOwnerLease(sess), base64.StdEncoding.EncodeToString([]byte("queued")))
484+ d.forwardClipboardAsync(clipboardOwnerLease(t, sess), base64.StdEncoding.EncodeToString([]byte("queued")))
482485
483486 newTransport := &closeTrackingTransport{}
484487 next := &attachedClient{tr: newTransport, output: newOutputStateStream(), size: old.size}
@@ -501,7 +504,7 @@ func TestForwardClipboardAsyncSerializesClipboardWrites(t *testing.T) {
501504 tr := newBlockingClipboardTransport()
502505 ac.replaceTransport(tr)
503506 publishActiveClipboardCapability(d, sess, ac, tr)
504- owner := clipboardOwnerLease(sess)
507+ owner := clipboardOwnerLease(t, sess)
505508
506509 first := base64.StdEncoding.EncodeToString([]byte("first"))
507510 second := base64.StdEncoding.EncodeToString([]byte("second"))
@@ -209,11 +209,15 @@ func publishActiveClipboardCapability(d *Daemon, sess *session, ac *attachedClie
209 ac.publishRoleCapability(token)209 ac.publishRoleCapability(token)
210 }210 }
211 211
212-func clipboardOwnerLease(sess *session) paneEffectLease {212+func clipboardOwnerLease(t *testing.T, sess *session) paneEffectLease {
213+ t.Helper()
213 tb := sess.activeTab()214 tb := sess.activeTab()
214 tb.mu.Lock()215 tb.mu.Lock()
215 p := tb.terminalTargetLocked()216 p := tb.terminalTargetLocked()
216 tb.mu.Unlock()217 tb.mu.Unlock()
218+ if p == nil {
219+ t.Fatal("clipboard owner has no terminal target")
220+ }
217 return p.effectLease()221 return p.effectLease()
218 }222 }
219 223
@@ -348,7 +352,7 @@ func TestQueuedClipboardAfterPaneMoveDoesNotSendToFormerOwner(t *testing.T) {
348 sess.mu.Lock()352 sess.mu.Lock()
349 sess.clipboardWorkerRunning = true353 sess.clipboardWorkerRunning = true
350 sess.mu.Unlock()354 sess.mu.Unlock()
351- d.forwardClipboardAsync(clipboardOwnerLease(sess), base64.StdEncoding.EncodeToString([]byte("queued")))355+ d.forwardClipboardAsync(clipboardOwnerLease(t, sess), base64.StdEncoding.EncodeToString([]byte("queued")))
352 356
353 sourceTab := sess.activeTab()357 sourceTab := sess.activeTab()
354 p := sourceTab.focusedPane()358 p := sourceTab.focusedPane()
@@ -363,10 +367,11 @@ func TestQueuedClipboardAfterPaneMoveDoesNotSendToFormerOwner(t *testing.T) {
363 }367 }
364 368
365 type movingClipboardErrorTransport struct {369 type movingClipboardErrorTransport struct {
366- started chan struct{}370+ started chan struct{}
367- release chan struct{}371+ release chan struct{}
368- closed chan struct{}372+ closed chan struct{}
369- closeOnce sync.Once373+ startedOnce sync.Once
374+ closeOnce sync.Once
370 }375 }
371 376
372 func newMovingClipboardErrorTransport() *movingClipboardErrorTransport {377 func newMovingClipboardErrorTransport() *movingClipboardErrorTransport {
@@ -378,7 +383,7 @@ func newMovingClipboardErrorTransport() *movingClipboardErrorTransport {
378 }383 }
379 384
380 func (t *movingClipboardErrorTransport) Send(ports.Frame) error {385 func (t *movingClipboardErrorTransport) Send(ports.Frame) error {
381- close(t.started)386+ t.startedOnce.Do(func() { close(t.started) })
382 <-t.release387 <-t.release
383 return errors.New("clipboard send failed after pane move")388 return errors.New("clipboard send failed after pane move")
384 }389 }
@@ -399,7 +404,7 @@ func TestQueuedClipboardRevalidatesOwnerAfterWaitingForClientSendLock(t *testing
399 sess.mu.Lock()404 sess.mu.Lock()
400 sess.clipboardWorkerRunning = true405 sess.clipboardWorkerRunning = true
401 sess.mu.Unlock()406 sess.mu.Unlock()
402- d.forwardClipboardAsync(clipboardOwnerLease(sess), base64.StdEncoding.EncodeToString([]byte("queued")))407+ d.forwardClipboardAsync(clipboardOwnerLease(t, sess), base64.StdEncoding.EncodeToString([]byte("queued")))
403 408
404 ac.sendMu.Lock()409 ac.sendMu.Lock()
405 sendMuLocked := true410 sendMuLocked := true
@@ -414,9 +419,7 @@ func TestQueuedClipboardRevalidatesOwnerAfterWaitingForClientSendLock(t *testing
414 close(workerDone)419 close(workerDone)
415 }()420 }()
416 require.Eventually(t, func() bool {421 require.Eventually(t, func() bool {
417- ac.roleEffects.mu.Lock()422+ return ac.roleEffects.inFlightCount() == 1
418- defer ac.roleEffects.mu.Unlock()
419- return ac.roleEffects.inFlight == 1
420 }, time.Second, time.Millisecond, "clipboard send was not admitted before waiting on sendMu")423 }, time.Second, time.Millisecond, "clipboard send was not admitted before waiting on sendMu")
421 424
422 sourceTab := sess.activeTab()425 sourceTab := sess.activeTab()
@@ -441,7 +444,7 @@ func TestClipboardSendErrorAfterPaneMoveDoesNotDetachFormerOwner(t *testing.T) {
441 sess.mu.Lock()444 sess.mu.Lock()
442 sess.clipboardWorkerRunning = true445 sess.clipboardWorkerRunning = true
443 sess.mu.Unlock()446 sess.mu.Unlock()
444- d.forwardClipboardAsync(clipboardOwnerLease(sess), base64.StdEncoding.EncodeToString([]byte("queued")))447+ d.forwardClipboardAsync(clipboardOwnerLease(t, sess), base64.StdEncoding.EncodeToString([]byte("queued")))
445 448
446 workerDone := make(chan struct{})449 workerDone := make(chan struct{})
447 go func() {450 go func() {
@@ -478,7 +481,7 @@ func TestQueuedClipboardBeforeSnatchDropsExactStaleCapability(t *testing.T) {
478 sess.mu.Lock()481 sess.mu.Lock()
479 sess.clipboardWorkerRunning = true482 sess.clipboardWorkerRunning = true
480 sess.mu.Unlock()483 sess.mu.Unlock()
481- d.forwardClipboardAsync(clipboardOwnerLease(sess), base64.StdEncoding.EncodeToString([]byte("queued")))484+ d.forwardClipboardAsync(clipboardOwnerLease(t, sess), base64.StdEncoding.EncodeToString([]byte("queued")))
482 485
483 newTransport := &closeTrackingTransport{}486 newTransport := &closeTrackingTransport{}
484 next := &attachedClient{tr: newTransport, output: newOutputStateStream(), size: old.size}487 next := &attachedClient{tr: newTransport, output: newOutputStateStream(), size: old.size}
@@ -501,7 +504,7 @@ func TestForwardClipboardAsyncSerializesClipboardWrites(t *testing.T) {
501 tr := newBlockingClipboardTransport()504 tr := newBlockingClipboardTransport()
502 ac.replaceTransport(tr)505 ac.replaceTransport(tr)
503 publishActiveClipboardCapability(d, sess, ac, tr)506 publishActiveClipboardCapability(d, sess, ac, tr)
504- owner := clipboardOwnerLease(sess)507+ owner := clipboardOwnerLease(t, sess)
505 508
506 first := base64.StdEncoding.EncodeToString([]byte("first"))509 first := base64.StdEncoding.EncodeToString([]byte("first"))
507 second := base64.StdEncoding.EncodeToString([]byte("second"))510 second := base64.StdEncoding.EncodeToString([]byte("second"))
M internal/usecase/daemon/control.go +5 -0
@@ -53,6 +53,11 @@ func (d *Daemon) dispatchCommand(ctx context.Context, request ports.CommandReque
5353 return commandFailure(code, text)
5454 }
5555 if cmd.Scope == command.CommandScopeCrossSession {
56+ // Cross-session moves intentionally skip sess.dispatchMu: movePane and
57+ // moveTab acquire both session dispatch locks through lockMoveDispatch in
58+ // global order. Pre-locking the source here could recreate the
59+ // opposite-direction deadlock covered by
60+ // TestHandleCommandOppositeMoveCommandsDoNotDeadlock.
5661 tb, pane, code, text := resolveControlTarget(sess, cmd.Target, request)
5762 if code != 0 {
5863 return commandFailure(code, text)
@@ -53,6 +53,11 @@ func (d *Daemon) dispatchCommand(ctx context.Context, request ports.CommandReque
53 return commandFailure(code, text)53 return commandFailure(code, text)
54 }54 }
55 if cmd.Scope == command.CommandScopeCrossSession {55 if cmd.Scope == command.CommandScopeCrossSession {
56+ // Cross-session moves intentionally skip sess.dispatchMu: movePane and
57+ // moveTab acquire both session dispatch locks through lockMoveDispatch in
58+ // global order. Pre-locking the source here could recreate the
59+ // opposite-direction deadlock covered by
60+ // TestHandleCommandOppositeMoveCommandsDoNotDeadlock.
56 tb, pane, code, text := resolveControlTarget(sess, cmd.Target, request)61 tb, pane, code, text := resolveControlTarget(sess, cmd.Target, request)
57 if code != 0 {62 if code != 0 {
58 return commandFailure(code, text)63 return commandFailure(code, text)
M internal/usecase/daemon/control_test.go +16 -9
@@ -958,24 +958,31 @@ func TestHandleCommandOppositeMoveCommandsDoNotDeadlock(t *testing.T) {
958958 right.active = 1
959959 right.mu.Unlock()
960960
961+ leftFrame := commandFrame(t, ports.CommandRequest{
962+ Slug: "move-tab", Args: []string{"right"}, TargetSession: "left",
963+ })
964+ rightFrame := commandFrame(t, ports.CommandRequest{
965+ Slug: "move-tab", Args: []string{"left"}, TargetSession: "right",
966+ })
967+ leftTransport, leftSends, _ := newConn(t, leftFrame)
968+ rightTransport, rightSends, _ := newConn(t, rightFrame)
961969 start := make(chan struct{})
962- done := make(chan ports.CommandResult, 2)
970+ done := make(chan struct{}, 2)
963971 go func() {
964972 <-start
965- done <- sendCommand(t, d, ports.CommandRequest{
966- Slug: "move-tab", Args: []string{"right"}, TargetSession: "left",
967- })
973+ d.handleCommand(leftTransport, leftFrame)
974+ done <- struct{}{}
968975 }()
969976 go func() {
970977 <-start
971- done <- sendCommand(t, d, ports.CommandRequest{
972- Slug: "move-tab", Args: []string{"left"}, TargetSession: "right",
973- })
978+ d.handleCommand(rightTransport, rightFrame)
979+ done <- struct{}{}
974980 }()
975981 close(start)
976- for range 2 {
982+ for _, sends := range []chan ports.Frame{leftSends, rightSends} {
977983 select {
978- case result := <-done:
984+ case <-done:
985+ result := awaitCommandResult(t, sends)
979986 require.True(t, result.OK, result.Text)
980987 case <-time.After(5 * time.Second):
981988 t.Fatal("opposite-direction move commands deadlocked")
@@ -958,24 +958,31 @@ func TestHandleCommandOppositeMoveCommandsDoNotDeadlock(t *testing.T) {
958 right.active = 1958 right.active = 1
959 right.mu.Unlock()959 right.mu.Unlock()
960 960
961+ leftFrame := commandFrame(t, ports.CommandRequest{
962+ Slug: "move-tab", Args: []string{"right"}, TargetSession: "left",
963+ })
964+ rightFrame := commandFrame(t, ports.CommandRequest{
965+ Slug: "move-tab", Args: []string{"left"}, TargetSession: "right",
966+ })
967+ leftTransport, leftSends, _ := newConn(t, leftFrame)
968+ rightTransport, rightSends, _ := newConn(t, rightFrame)
961 start := make(chan struct{})969 start := make(chan struct{})
962- done := make(chan ports.CommandResult, 2)970+ done := make(chan struct{}, 2)
963 go func() {971 go func() {
964 <-start972 <-start
965- done <- sendCommand(t, d, ports.CommandRequest{973+ d.handleCommand(leftTransport, leftFrame)
966- Slug: "move-tab", Args: []string{"right"}, TargetSession: "left",974+ done <- struct{}{}
967- })
968 }()975 }()
969 go func() {976 go func() {
970 <-start977 <-start
971- done <- sendCommand(t, d, ports.CommandRequest{978+ d.handleCommand(rightTransport, rightFrame)
972- Slug: "move-tab", Args: []string{"left"}, TargetSession: "right",979+ done <- struct{}{}
973- })
974 }()980 }()
975 close(start)981 close(start)
976- for range 2 {982+ for _, sends := range []chan ports.Frame{leftSends, rightSends} {
977 select {983 select {
978- case result := <-done:984+ case <-done:
985+ result := awaitCommandResult(t, sends)
979 require.True(t, result.OK, result.Text)986 require.True(t, result.OK, result.Text)
980 case <-time.After(5 * time.Second):987 case <-time.After(5 * time.Second):
981 t.Fatal("opposite-direction move commands deadlocked")988 t.Fatal("opposite-direction move commands deadlocked")
M internal/usecase/daemon/copymode_lifecycle_test.go +29 -24
@@ -55,32 +55,37 @@ func TestCopyModeLifecycleActivateTabClearsRuntime(t *testing.T) {
5555 ac.overlays.copyMu.Unlock()
5656 }
5757
58-func TestActivateTabAfterResizeWithClientAndNoLeaseUsesDirectPath(t *testing.T) {
59- d, sess, ac, _, releases := newManualTabSession(t, 2)
60- defer releaseAll(releases)
61- d.enterCopyMode(sess, ac)
62- require.True(t, ac.overlays.copyActive())
63-
64- d.activateTabAfterResizeForLease(sess, sess.activeTab(), true, ac, nil)
65-
66- require.False(t, ac.overlays.copyActive())
67-}
68-
69-func TestActivateTabAfterResizeWithCurrentLeaseUsesLeaseBackedPath(t *testing.T) {
70- d, sess, ac, _, releases := newManualTabSession(t, 2)
71- defer releaseAll(releases)
72- d.enterCopyMode(sess, ac)
73- require.True(t, ac.overlays.copyActive())
74- rc := newRenderCoordinator(renderCoordinatorOptions{clock: d.clock})
75- rc.attach(ac)
76- sess.installRenderCoordinator(rc)
77- defer func() { rc.beginSessionTeardown().finish() }()
78- lease := rc.attachmentLease(ac)
79- require.NotNil(t, lease)
58+func TestActivateTabAfterResizePathAndCopyModeLifecycle(t *testing.T) {
59+ for _, tt := range []struct {
60+ name string
61+ leaseBacked bool
62+ }{
63+ {name: "direct", leaseBacked: false},
64+ {name: "lease-backed", leaseBacked: true},
65+ } {
66+ t.Run(tt.name, func(t *testing.T) {
67+ d, sess, ac, _, releases := newManualTabSession(t, 2)
68+ defer releaseAll(releases)
69+ rc := newRenderCoordinator(renderCoordinatorOptions{clock: d.clock})
70+ rc.attach(ac)
71+ sess.installRenderCoordinator(rc)
72+ defer func() { rc.beginSessionTeardown().finish() }()
73+ path := make(chan bool, 1)
74+ rc.opts.onActivateTabAfterResize = func(leaseBacked bool) { path <- leaseBacked }
75+ var lease *attachmentLease
76+ if tt.leaseBacked {
77+ lease = rc.attachmentLease(ac)
78+ require.NotNil(t, lease)
79+ }
8080
81- d.activateTabAfterResizeForLease(sess, sess.activeTab(), true, ac, lease)
81+ d.enterCopyMode(sess, ac)
82+ require.True(t, ac.overlays.copyActive())
83+ d.activateTabAfterResizeForLease(sess, sess.activeTab(), true, ac, lease)
8284
83- require.False(t, ac.overlays.copyActive())
85+ require.False(t, ac.overlays.copyActive())
86+ require.Equal(t, tt.leaseBacked, <-path)
87+ })
88+ }
8489 }
8590
8691 func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) {
@@ -55,32 +55,37 @@ func TestCopyModeLifecycleActivateTabClearsRuntime(t *testing.T) {
55 ac.overlays.copyMu.Unlock()55 ac.overlays.copyMu.Unlock()
56 }56 }
57 57
58-func TestActivateTabAfterResizeWithClientAndNoLeaseUsesDirectPath(t *testing.T) {58+func TestActivateTabAfterResizePathAndCopyModeLifecycle(t *testing.T) {
59- d, sess, ac, _, releases := newManualTabSession(t, 2)59+ for _, tt := range []struct {
60- defer releaseAll(releases)60+ name string
61- d.enterCopyMode(sess, ac)61+ leaseBacked bool
62- require.True(t, ac.overlays.copyActive())62+ }{
63-63+ {name: "direct", leaseBacked: false},
64- d.activateTabAfterResizeForLease(sess, sess.activeTab(), true, ac, nil)64+ {name: "lease-backed", leaseBacked: true},
65-65+ } {
66- require.False(t, ac.overlays.copyActive())66+ t.Run(tt.name, func(t *testing.T) {
67-}67+ d, sess, ac, _, releases := newManualTabSession(t, 2)
68-68+ defer releaseAll(releases)
69-func TestActivateTabAfterResizeWithCurrentLeaseUsesLeaseBackedPath(t *testing.T) {69+ rc := newRenderCoordinator(renderCoordinatorOptions{clock: d.clock})
70- d, sess, ac, _, releases := newManualTabSession(t, 2)70+ rc.attach(ac)
71- defer releaseAll(releases)71+ sess.installRenderCoordinator(rc)
72- d.enterCopyMode(sess, ac)72+ defer func() { rc.beginSessionTeardown().finish() }()
73- require.True(t, ac.overlays.copyActive())73+ path := make(chan bool, 1)
74- rc := newRenderCoordinator(renderCoordinatorOptions{clock: d.clock})74+ rc.opts.onActivateTabAfterResize = func(leaseBacked bool) { path <- leaseBacked }
75- rc.attach(ac)75+ var lease *attachmentLease
76- sess.installRenderCoordinator(rc)76+ if tt.leaseBacked {
77- defer func() { rc.beginSessionTeardown().finish() }()77+ lease = rc.attachmentLease(ac)
78- lease := rc.attachmentLease(ac)78+ require.NotNil(t, lease)
79- require.NotNil(t, lease)79+ }
80 80
81- d.activateTabAfterResizeForLease(sess, sess.activeTab(), true, ac, lease)81+ d.enterCopyMode(sess, ac)
82+ require.True(t, ac.overlays.copyActive())
83+ d.activateTabAfterResizeForLease(sess, sess.activeTab(), true, ac, lease)
82 84
83- require.False(t, ac.overlays.copyActive())85+ require.False(t, ac.overlays.copyActive())
86+ require.Equal(t, tt.leaseBacked, <-path)
87+ })
88+ }
84 }89 }
85 90
86 func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) {91 func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) {
M internal/usecase/daemon/daemon.go +29 -11
@@ -160,11 +160,12 @@ type Daemon struct {
160160 // attachment snapshot respectively. beforeMovePaneCommit runs inside the
161161 // non-failing publication section. None is set in production, and none may
162162 // perform external work while locks are held.
163- afterMoveLifecycleReserved func()
164- afterMovePaneSourceSnapshot func()
165- afterMoveTabSourceSnapshot func()
166- beforeMovePaneCommit func()
167- beforeMoveTabCommit func()
163+ afterMoveLifecycleReserved func()
164+ afterMoveLifecycleGateBeforeTeardownLocks func()
165+ afterMovePaneSourceSnapshot func()
166+ afterMoveTabSourceSnapshot func()
167+ beforeMovePaneCommit func()
168+ beforeMoveTabCommit func()
168169 // afterDetachRoleEffectsFrozen observes terminal detach after it wins the
169170 // attachment gate but before it checks session ownership.
170171 afterDetachRoleEffectsFrozen func()
@@ -1070,9 +1071,11 @@ type protoErr struct {
10701071
10711072 func (e *protoErr) Error() string { return e.text }
10721073
1073-// finishAttach completes an attachment prepared while d.mu is held. It
1074-// publishes terminal and role state before releasing d.mu, then defers
1075-// coordinator cleanup so obsolete workers never delay the new handshake.
1074+// finishAttach completes an attachment prepared while d.mu is held. Every
1075+// caller must hold d.mu on entry; finishAttach transfers ownership by
1076+// unlocking d.mu before returning, including every error path. It publishes
1077+// terminal and role state before releasing d.mu, then defers coordinator
1078+// cleanup so obsolete workers never delay the new handshake.
10761079 func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size, term terminalEnv, h ports.Hello) (*attachedClient, error) {
10771080 // Session state is the sole source for future PTY children. Update it before
10781081 // publishing the attachment; existing PTYs keep their original environment.
@@ -1166,10 +1169,25 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
11661169 // expired, or raced with lifecycle teardown, fail closed instead of routing
11671170 // the Hello as an ordinary attach that could create or replace ownership.
11681171 if h.ResumeToken != 0 {
1169- if sess, ac, ok, err := d.resumeParked(h, tr, sz); ok || err != nil {
1170- return sess, ac, err
1172+ d.mu.Lock()
1173+ parkedAtStart := d.parked[h.ResumeToken]
1174+ d.mu.Unlock()
1175+ if parkedAtStart == nil {
1176+ return nil, nil, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
1177+ }
1178+ if sess, ac, ok, err := d.resumeParked(h, tr, sz); err == nil {
1179+ if ok {
1180+ return sess, ac, nil
1181+ }
1182+ return nil, nil, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
1183+ } else if errors.Is(err, errResumeTokenLifecycleRace) {
1184+ // The parked entry was replaced while this handshake waited for
1185+ // its send lock. The ordinary attach path can reclaim the still-live
1186+ // named session instead of turning this grace-period race into a
1187+ // terminal no-such-session error.
1188+ } else {
1189+ return nil, nil, err
11711190 }
1172- return nil, nil, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
11731191 }
11741192 if h.Intent == ports.IntentResume || h.Intent == ports.IntentAttach {
11751193 ctx := d.serveCtx
@@ -160,11 +160,12 @@ type Daemon struct {
160 // attachment snapshot respectively. beforeMovePaneCommit runs inside the160 // attachment snapshot respectively. beforeMovePaneCommit runs inside the
161 // non-failing publication section. None is set in production, and none may161 // non-failing publication section. None is set in production, and none may
162 // perform external work while locks are held.162 // perform external work while locks are held.
163- afterMoveLifecycleReserved func()163+ afterMoveLifecycleReserved func()
164- afterMovePaneSourceSnapshot func()164+ afterMoveLifecycleGateBeforeTeardownLocks func()
165- afterMoveTabSourceSnapshot func()165+ afterMovePaneSourceSnapshot func()
166- beforeMovePaneCommit func()166+ afterMoveTabSourceSnapshot func()
167- beforeMoveTabCommit func()167+ beforeMovePaneCommit func()
168+ beforeMoveTabCommit func()
168 // afterDetachRoleEffectsFrozen observes terminal detach after it wins the169 // afterDetachRoleEffectsFrozen observes terminal detach after it wins the
169 // attachment gate but before it checks session ownership.170 // attachment gate but before it checks session ownership.
170 afterDetachRoleEffectsFrozen func()171 afterDetachRoleEffectsFrozen func()
@@ -1070,9 +1071,11 @@ type protoErr struct {
1070 1071
1071 func (e *protoErr) Error() string { return e.text }1072 func (e *protoErr) Error() string { return e.text }
1072 1073
1073-// finishAttach completes an attachment prepared while d.mu is held. It1074+// finishAttach completes an attachment prepared while d.mu is held. Every
1074-// publishes terminal and role state before releasing d.mu, then defers1075+// caller must hold d.mu on entry; finishAttach transfers ownership by
1075-// coordinator cleanup so obsolete workers never delay the new handshake.1076+// unlocking d.mu before returning, including every error path. It publishes
1077+// terminal and role state before releasing d.mu, then defers coordinator
1078+// cleanup so obsolete workers never delay the new handshake.
1076 func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size, term terminalEnv, h ports.Hello) (*attachedClient, error) {1079 func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size, term terminalEnv, h ports.Hello) (*attachedClient, error) {
1077 // Session state is the sole source for future PTY children. Update it before1080 // Session state is the sole source for future PTY children. Update it before
1078 // publishing the attachment; existing PTYs keep their original environment.1081 // publishing the attachment; existing PTYs keep their original environment.
@@ -1166,10 +1169,25 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
1166 // expired, or raced with lifecycle teardown, fail closed instead of routing1169 // expired, or raced with lifecycle teardown, fail closed instead of routing
1167 // the Hello as an ordinary attach that could create or replace ownership.1170 // the Hello as an ordinary attach that could create or replace ownership.
1168 if h.ResumeToken != 0 {1171 if h.ResumeToken != 0 {
1169- if sess, ac, ok, err := d.resumeParked(h, tr, sz); ok || err != nil {1172+ d.mu.Lock()
1170- return sess, ac, err1173+ parkedAtStart := d.parked[h.ResumeToken]
1174+ d.mu.Unlock()
1175+ if parkedAtStart == nil {
1176+ return nil, nil, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
1177+ }
1178+ if sess, ac, ok, err := d.resumeParked(h, tr, sz); err == nil {
1179+ if ok {
1180+ return sess, ac, nil
1181+ }
1182+ return nil, nil, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
1183+ } else if errors.Is(err, errResumeTokenLifecycleRace) {
1184+ // The parked entry was replaced while this handshake waited for
1185+ // its send lock. The ordinary attach path can reclaim the still-live
1186+ // named session instead of turning this grace-period race into a
1187+ // terminal no-such-session error.
1188+ } else {
1189+ return nil, nil, err
1171 }1190 }
1172- return nil, nil, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
1173 }1191 }
1174 if h.Intent == ports.IntentResume || h.Intent == ports.IntentAttach {1192 if h.Intent == ports.IntentResume || h.Intent == ports.IntentAttach {
1175 ctx := d.serveCtx1193 ctx := d.serveCtx
M internal/usecase/daemon/floating.go +3 -0
@@ -186,6 +186,9 @@ func (d *Daemon) activateTabAfterResizeForLease(sess *session, tb *tab, outerRes
186186 ac = sess.client
187187 sess.mu.Unlock()
188188 }
189+ if rc := sess.renderCoordinator(); rc != nil && rc.opts.onActivateTabAfterResize != nil {
190+ rc.opts.onActivateTabAfterResize(lease != nil)
191+ }
189192 if lease != nil {
190193 rc := sess.renderCoordinator()
191194 if rc == nil || !rc.leaseCurrent(lease, true) {
@@ -186,6 +186,9 @@ func (d *Daemon) activateTabAfterResizeForLease(sess *session, tb *tab, outerRes
186 ac = sess.client186 ac = sess.client
187 sess.mu.Unlock()187 sess.mu.Unlock()
188 }188 }
189+ if rc := sess.renderCoordinator(); rc != nil && rc.opts.onActivateTabAfterResize != nil {
190+ rc.opts.onActivateTabAfterResize(lease != nil)
191+ }
189 if lease != nil {192 if lease != nil {
190 rc := sess.renderCoordinator()193 rc := sess.renderCoordinator()
191 if rc == nil || !rc.leaseCurrent(lease, true) {194 if rc == nil || !rc.leaseCurrent(lease, true) {
M internal/usecase/daemon/floating_test.go +2 -2
@@ -298,8 +298,8 @@ func TestFloatingLifecycleStaleSuccessAndOldExitCannotReplaceCurrentSlot(t *test
298298 g1 := tb.beginFloatingWarmLocked(true)
299299 publishPaneOwner(first, sess, tb, g1)
300300 require.True(t, tb.installFloatingLocked(first, g1))
301- g2 := tb.clearFloatingLocked(first, g1)
302- require.True(t, g2)
301+ clearSucceeded := tb.clearFloatingLocked(first, g1)
302+ require.True(t, clearSucceeded)
303303 generation := tb.beginFloatingWarmLocked(true)
304304 require.True(t, tb.installFloatingLocked(second, generation))
305305 tb.mu.Unlock()
@@ -298,8 +298,8 @@ func TestFloatingLifecycleStaleSuccessAndOldExitCannotReplaceCurrentSlot(t *test
298 g1 := tb.beginFloatingWarmLocked(true)298 g1 := tb.beginFloatingWarmLocked(true)
299 publishPaneOwner(first, sess, tb, g1)299 publishPaneOwner(first, sess, tb, g1)
300 require.True(t, tb.installFloatingLocked(first, g1))300 require.True(t, tb.installFloatingLocked(first, g1))
301- g2 := tb.clearFloatingLocked(first, g1)301+ clearSucceeded := tb.clearFloatingLocked(first, g1)
302- require.True(t, g2)302+ require.True(t, clearSucceeded)
303 generation := tb.beginFloatingWarmLocked(true)303 generation := tb.beginFloatingWarmLocked(true)
304 require.True(t, tb.installFloatingLocked(second, generation))304 require.True(t, tb.installFloatingLocked(second, generation))
305 tb.mu.Unlock()305 tb.mu.Unlock()
M internal/usecase/daemon/input.go +5 -4
@@ -3,6 +3,7 @@ package daemon
33
44 import (
55 "bytes"
6+ "errors"
67 "strconv"
78
89 "github.com/bnema/vev/internal/domain"
@@ -388,19 +389,19 @@ func (h daemonKeyHandler) Action(action keys.Action) {
388389 h.d.reportError(sess, err)
389390 }
390391 case keys.ActionFocusPaneLeft:
391- if err := h.d.focusDir(sess, h.ac, layout.Left, effect); err != nil && err != errAttachmentTransition {
392+ if err := h.d.focusDir(sess, h.ac, layout.Left, effect); err != nil && !errors.Is(err, errAttachmentTransition) {
392393 h.d.reportError(sess, err)
393394 }
394395 case keys.ActionFocusPaneRight:
395- if err := h.d.focusDir(sess, h.ac, layout.Right, effect); err != nil && err != errAttachmentTransition {
396+ if err := h.d.focusDir(sess, h.ac, layout.Right, effect); err != nil && !errors.Is(err, errAttachmentTransition) {
396397 h.d.reportError(sess, err)
397398 }
398399 case keys.ActionFocusPaneUp:
399- if err := h.d.focusDir(sess, h.ac, layout.Up, effect); err != nil && err != errAttachmentTransition {
400+ if err := h.d.focusDir(sess, h.ac, layout.Up, effect); err != nil && !errors.Is(err, errAttachmentTransition) {
400401 h.d.reportError(sess, err)
401402 }
402403 case keys.ActionFocusPaneDown:
403- if err := h.d.focusDir(sess, h.ac, layout.Down, effect); err != nil && err != errAttachmentTransition {
404+ if err := h.d.focusDir(sess, h.ac, layout.Down, effect); err != nil && !errors.Is(err, errAttachmentTransition) {
404405 h.d.reportError(sess, err)
405406 }
406407 case keys.ActionGrowPaneWidth:
@@ -3,6 +3,7 @@ package daemon
3 3
4 import (4 import (
5 "bytes"5 "bytes"
6+ "errors"
6 "strconv"7 "strconv"
7 8
8 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
@@ -388,19 +389,19 @@ func (h daemonKeyHandler) Action(action keys.Action) {
388 h.d.reportError(sess, err)389 h.d.reportError(sess, err)
389 }390 }
390 case keys.ActionFocusPaneLeft:391 case keys.ActionFocusPaneLeft:
391- if err := h.d.focusDir(sess, h.ac, layout.Left, effect); err != nil && err != errAttachmentTransition {392+ if err := h.d.focusDir(sess, h.ac, layout.Left, effect); err != nil && !errors.Is(err, errAttachmentTransition) {
392 h.d.reportError(sess, err)393 h.d.reportError(sess, err)
393 }394 }
394 case keys.ActionFocusPaneRight:395 case keys.ActionFocusPaneRight:
395- if err := h.d.focusDir(sess, h.ac, layout.Right, effect); err != nil && err != errAttachmentTransition {396+ if err := h.d.focusDir(sess, h.ac, layout.Right, effect); err != nil && !errors.Is(err, errAttachmentTransition) {
396 h.d.reportError(sess, err)397 h.d.reportError(sess, err)
397 }398 }
398 case keys.ActionFocusPaneUp:399 case keys.ActionFocusPaneUp:
399- if err := h.d.focusDir(sess, h.ac, layout.Up, effect); err != nil && err != errAttachmentTransition {400+ if err := h.d.focusDir(sess, h.ac, layout.Up, effect); err != nil && !errors.Is(err, errAttachmentTransition) {
400 h.d.reportError(sess, err)401 h.d.reportError(sess, err)
401 }402 }
402 case keys.ActionFocusPaneDown:403 case keys.ActionFocusPaneDown:
403- if err := h.d.focusDir(sess, h.ac, layout.Down, effect); err != nil && err != errAttachmentTransition {404+ if err := h.d.focusDir(sess, h.ac, layout.Down, effect); err != nil && !errors.Is(err, errAttachmentTransition) {
404 h.d.reportError(sess, err)405 h.d.reportError(sess, err)
405 }406 }
406 case keys.ActionGrowPaneWidth:407 case keys.ActionGrowPaneWidth:
M internal/usecase/daemon/move.go +18 -4
@@ -1,6 +1,8 @@
11 package daemon
22
33 import (
4+ "reflect"
5+
46 "github.com/bnema/vev/internal/domain"
57 )
68
@@ -126,11 +128,11 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) {
126128 }
127129 source.mu.Unlock()
128130 d.mu.Unlock()
129- frozen = tryFreezeRoleEffectGatesInterruptingObserved(handoffParticipants.interrupts, func(ac *attachedClient) {
131+ frozen = freezeRoleEffectGatesWith(roleEffectFreezeOptions{interrupts: handoffParticipants.interrupts, nonblocking: true, afterFrozen: func(ac *attachedClient) {
130132 if d.afterRoleEffectGateFrozen != nil {
131133 d.afterRoleEffectGateFrozen("move-pane", ac)
132134 }
133- }, handoffParticipants.clients...)
135+ }}, handoffParticipants.clients...)
134136 if !frozen.acquired || !frozen.drained {
135137 frozen.unfreeze()
136138 return errMovePaneInvalid
@@ -152,7 +154,7 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) {
152154 }
153155 source.mu.Unlock()
154156 d.mu.Unlock()
155- frozen = tryFreezeRoleEffectGatesInterruptingObserved(interrupts, nil, participants...)
157+ frozen = freezeRoleEffectGatesWith(roleEffectFreezeOptions{interrupts: interrupts, nonblocking: true}, participants...)
156158 if !frozen.acquired || !frozen.drained {
157159 frozen.unfreeze()
158160 return errMovePaneInvalid
@@ -278,12 +280,24 @@ func moveTabMemberLocked(sess *session, target *tab) bool {
278280 }
279281
280282 func lockMoveTabs(a, b *tab) func() {
283+ if a == nil && b == nil {
284+ return func() {}
285+ }
286+ if a == nil {
287+ b.mu.Lock()
288+ return b.mu.Unlock
289+ }
290+ if b == nil {
291+ a.mu.Lock()
292+ return a.mu.Unlock
293+ }
281294 if a == b {
282295 a.mu.Lock()
283296 return a.mu.Unlock
284297 }
285298 first, second := a, b
286- if first.stableID > second.stableID {
299+ if first.stableID > second.stableID ||
300+ (first.stableID == second.stableID && reflect.ValueOf(first).Pointer() > reflect.ValueOf(second).Pointer()) {
287301 first, second = second, first
288302 }
289303 first.mu.Lock()
@@ -1,6 +1,8 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "reflect"
5+
4 "github.com/bnema/vev/internal/domain"6 "github.com/bnema/vev/internal/domain"
5 )7 )
6 8
@@ -126,11 +128,11 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) {
126 }128 }
127 source.mu.Unlock()129 source.mu.Unlock()
128 d.mu.Unlock()130 d.mu.Unlock()
129- frozen = tryFreezeRoleEffectGatesInterruptingObserved(handoffParticipants.interrupts, func(ac *attachedClient) {131+ frozen = freezeRoleEffectGatesWith(roleEffectFreezeOptions{interrupts: handoffParticipants.interrupts, nonblocking: true, afterFrozen: func(ac *attachedClient) {
130 if d.afterRoleEffectGateFrozen != nil {132 if d.afterRoleEffectGateFrozen != nil {
131 d.afterRoleEffectGateFrozen("move-pane", ac)133 d.afterRoleEffectGateFrozen("move-pane", ac)
132 }134 }
133- }, handoffParticipants.clients...)135+ }}, handoffParticipants.clients...)
134 if !frozen.acquired || !frozen.drained {136 if !frozen.acquired || !frozen.drained {
135 frozen.unfreeze()137 frozen.unfreeze()
136 return errMovePaneInvalid138 return errMovePaneInvalid
@@ -152,7 +154,7 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) {
152 }154 }
153 source.mu.Unlock()155 source.mu.Unlock()
154 d.mu.Unlock()156 d.mu.Unlock()
155- frozen = tryFreezeRoleEffectGatesInterruptingObserved(interrupts, nil, participants...)157+ frozen = freezeRoleEffectGatesWith(roleEffectFreezeOptions{interrupts: interrupts, nonblocking: true}, participants...)
156 if !frozen.acquired || !frozen.drained {158 if !frozen.acquired || !frozen.drained {
157 frozen.unfreeze()159 frozen.unfreeze()
158 return errMovePaneInvalid160 return errMovePaneInvalid
@@ -278,12 +280,24 @@ func moveTabMemberLocked(sess *session, target *tab) bool {
278 }280 }
279 281
280 func lockMoveTabs(a, b *tab) func() {282 func lockMoveTabs(a, b *tab) func() {
283+ if a == nil && b == nil {
284+ return func() {}
285+ }
286+ if a == nil {
287+ b.mu.Lock()
288+ return b.mu.Unlock
289+ }
290+ if b == nil {
291+ a.mu.Lock()
292+ return a.mu.Unlock
293+ }
281 if a == b {294 if a == b {
282 a.mu.Lock()295 a.mu.Lock()
283 return a.mu.Unlock296 return a.mu.Unlock
284 }297 }
285 first, second := a, b298 first, second := a, b
286- if first.stableID > second.stableID {299+ if first.stableID > second.stableID ||
300+ (first.stableID == second.stableID && reflect.ValueOf(first).Pointer() > reflect.ValueOf(second).Pointer()) {
287 first, second = second, first301 first, second = second, first
288 }302 }
289 first.mu.Lock()303 first.mu.Lock()
M internal/usecase/daemon/move_acceptance_test.go +13 -11
@@ -1,7 +1,6 @@
11 package daemon
22
33 import (
4- "errors"
54 "sync"
65 "testing"
76
@@ -11,8 +10,6 @@ import (
1110 "github.com/bnema/vev/internal/usecase/layout"
1211 )
1312
14-var errMoveCatalogueLocked = errors.New("move catalogue called while architecture locks were held")
15-
1613 func catalogueUpdateNames(updates []domain.CatalogueMetadataUpdate) []string {
1714 names := make([]string, 0, len(updates))
1815 for _, update := range updates {
@@ -22,11 +19,12 @@ func catalogueUpdateNames(updates []domain.CatalogueMetadataUpdate) []string {
2219 }
2320
2421 type moveCatalogueRecorder struct {
25- daemon *Daemon
26- source *session
27- destination *session
28- mu sync.Mutex
29- updates []domain.CatalogueMetadataUpdate
22+ daemon *Daemon
23+ source *session
24+ destination *session
25+ mu sync.Mutex
26+ updates []domain.CatalogueMetadataUpdate
27+ lockViolation bool
3028 }
3129
3230 func (c *moveCatalogueRecorder) Records() ([]domain.CatalogueRecord, error) { return nil, nil }
@@ -40,23 +38,25 @@ func (c *moveCatalogueRecorder) Delete(string) error {
4038 func (c *moveCatalogueRecorder) Sync() error { return nil }
4139 func (c *moveCatalogueRecorder) Close() error { return nil }
4240 func (c *moveCatalogueRecorder) UpdateMetadata(update domain.CatalogueMetadataUpdate) error {
41+ violated := false
4342 if c.daemon.mu.TryLock() {
4443 c.daemon.mu.Unlock()
4544 } else {
46- return errMoveCatalogueLocked
45+ violated = true
4746 }
4847 if c.source.mu.TryLock() {
4948 c.source.mu.Unlock()
5049 } else {
51- return errMoveCatalogueLocked
50+ violated = true
5251 }
5352 if c.destination.mu.TryLock() {
5453 c.destination.mu.Unlock()
5554 } else {
56- return errMoveCatalogueLocked
55+ violated = true
5756 }
5857 c.mu.Lock()
5958 c.updates = append(c.updates, update)
59+ c.lockViolation = c.lockViolation || violated
6060 c.mu.Unlock()
6161 return nil
6262 }
@@ -169,7 +169,9 @@ func TestMovePaneAcceptanceRetainsSourceFocusAndDestinationActivity(t *testing.T
169169 destination.snapshotMu.Unlock()
170170 catalogue.mu.Lock()
171171 updates := append([]domain.CatalogueMetadataUpdate(nil), catalogue.updates...)
172+ lockViolation := catalogue.lockViolation
172173 catalogue.mu.Unlock()
174+ require.False(t, lockViolation, "catalogue publication ran under an architecture lock")
173175 require.ElementsMatch(t, []string{source.name, destination.name}, catalogueUpdateNames(updates))
174176 }
175177
@@ -1,7 +1,6 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4- "errors"
5 "sync"4 "sync"
6 "testing"5 "testing"
7 6
@@ -11,8 +10,6 @@ import (
11 "github.com/bnema/vev/internal/usecase/layout"10 "github.com/bnema/vev/internal/usecase/layout"
12 )11 )
13 12
14-var errMoveCatalogueLocked = errors.New("move catalogue called while architecture locks were held")
15-
16 func catalogueUpdateNames(updates []domain.CatalogueMetadataUpdate) []string {13 func catalogueUpdateNames(updates []domain.CatalogueMetadataUpdate) []string {
17 names := make([]string, 0, len(updates))14 names := make([]string, 0, len(updates))
18 for _, update := range updates {15 for _, update := range updates {
@@ -22,11 +19,12 @@ func catalogueUpdateNames(updates []domain.CatalogueMetadataUpdate) []string {
22 }19 }
23 20
24 type moveCatalogueRecorder struct {21 type moveCatalogueRecorder struct {
25- daemon *Daemon22+ daemon *Daemon
26- source *session23+ source *session
27- destination *session24+ destination *session
28- mu sync.Mutex25+ mu sync.Mutex
29- updates []domain.CatalogueMetadataUpdate26+ updates []domain.CatalogueMetadataUpdate
27+ lockViolation bool
30 }28 }
31 29
32 func (c *moveCatalogueRecorder) Records() ([]domain.CatalogueRecord, error) { return nil, nil }30 func (c *moveCatalogueRecorder) Records() ([]domain.CatalogueRecord, error) { return nil, nil }
@@ -40,23 +38,25 @@ func (c *moveCatalogueRecorder) Delete(string) error {
40 func (c *moveCatalogueRecorder) Sync() error { return nil }38 func (c *moveCatalogueRecorder) Sync() error { return nil }
41 func (c *moveCatalogueRecorder) Close() error { return nil }39 func (c *moveCatalogueRecorder) Close() error { return nil }
42 func (c *moveCatalogueRecorder) UpdateMetadata(update domain.CatalogueMetadataUpdate) error {40 func (c *moveCatalogueRecorder) UpdateMetadata(update domain.CatalogueMetadataUpdate) error {
41+ violated := false
43 if c.daemon.mu.TryLock() {42 if c.daemon.mu.TryLock() {
44 c.daemon.mu.Unlock()43 c.daemon.mu.Unlock()
45 } else {44 } else {
46- return errMoveCatalogueLocked45+ violated = true
47 }46 }
48 if c.source.mu.TryLock() {47 if c.source.mu.TryLock() {
49 c.source.mu.Unlock()48 c.source.mu.Unlock()
50 } else {49 } else {
51- return errMoveCatalogueLocked50+ violated = true
52 }51 }
53 if c.destination.mu.TryLock() {52 if c.destination.mu.TryLock() {
54 c.destination.mu.Unlock()53 c.destination.mu.Unlock()
55 } else {54 } else {
56- return errMoveCatalogueLocked55+ violated = true
57 }56 }
58 c.mu.Lock()57 c.mu.Lock()
59 c.updates = append(c.updates, update)58 c.updates = append(c.updates, update)
59+ c.lockViolation = c.lockViolation || violated
60 c.mu.Unlock()60 c.mu.Unlock()
61 return nil61 return nil
62 }62 }
@@ -169,7 +169,9 @@ func TestMovePaneAcceptanceRetainsSourceFocusAndDestinationActivity(t *testing.T
169 destination.snapshotMu.Unlock()169 destination.snapshotMu.Unlock()
170 catalogue.mu.Lock()170 catalogue.mu.Lock()
171 updates := append([]domain.CatalogueMetadataUpdate(nil), catalogue.updates...)171 updates := append([]domain.CatalogueMetadataUpdate(nil), catalogue.updates...)
172+ lockViolation := catalogue.lockViolation
172 catalogue.mu.Unlock()173 catalogue.mu.Unlock()
174+ require.False(t, lockViolation, "catalogue publication ran under an architecture lock")
173 require.ElementsMatch(t, []string{source.name, destination.name}, catalogueUpdateNames(updates))175 require.ElementsMatch(t, []string{source.name, destination.name}, catalogueUpdateNames(updates))
174 }176 }
175 177
M internal/usecase/daemon/move_cleanup_test.go +9 -1
@@ -18,6 +18,7 @@ func TestMovePaneReleasesResizeFencesBeforeAttachmentCleanup(t *testing.T) {
1818 defer releaseDestination()
1919
2020 d, source, client, _ := newManualSessionWithPTYs(t, movedPTY, remainingPTY)
21+ require.NotNil(t, d.attachCoordinator(source, nil, client, true))
2122 sourceTab := source.tabs[0]
2223 sourceTab.stableID = "source-tab"
2324 moved := sourceTab.focusedPane()
@@ -40,6 +41,13 @@ func TestMovePaneReleasesResizeFencesBeforeAttachmentCleanup(t *testing.T) {
4041 owner := moved.effectLeaseLocked()
4142 moved.mu.Unlock()
4243 members := []resizeMember{{session: source, tab: sourceTab, pane: moved, owner: owner}}
44+ rc := source.renderCoordinator()
45+ require.NotNil(t, rc)
46+ lease := rc.attachmentLease(client)
47+ epoch := rc.recordResizeRequestForLease(client.size, client, lease)
48+ ticket, admitted := beginActiveLeaseEffect(source, client, lease)
49+ require.True(t, admitted)
50+ defer ticket.End()
4351
4452 sendLocked := make(chan struct{})
4553 allowResizeFence := make(chan struct{})
@@ -50,7 +58,7 @@ func TestMovePaneReleasesResizeFencesBeforeAttachmentCleanup(t *testing.T) {
5058 defer func() { d.afterResizeCommitSendLocked = nil }()
5159 resizeDone := make(chan bool, 1)
5260 go func() {
53- resizeDone <- d.publishResizeCommit(members, source, client, nil, 0, nil, client.size)
61+ resizeDone <- d.publishResizeCommit(members, source, client, lease, epoch, ticket, client.size)
5462 }()
5563 awaitTestCompletion(t, sendLocked, "resize commit did not acquire sendMu")
5664
@@ -18,6 +18,7 @@ func TestMovePaneReleasesResizeFencesBeforeAttachmentCleanup(t *testing.T) {
18 defer releaseDestination()18 defer releaseDestination()
19 19
20 d, source, client, _ := newManualSessionWithPTYs(t, movedPTY, remainingPTY)20 d, source, client, _ := newManualSessionWithPTYs(t, movedPTY, remainingPTY)
21+ require.NotNil(t, d.attachCoordinator(source, nil, client, true))
21 sourceTab := source.tabs[0]22 sourceTab := source.tabs[0]
22 sourceTab.stableID = "source-tab"23 sourceTab.stableID = "source-tab"
23 moved := sourceTab.focusedPane()24 moved := sourceTab.focusedPane()
@@ -40,6 +41,13 @@ func TestMovePaneReleasesResizeFencesBeforeAttachmentCleanup(t *testing.T) {
40 owner := moved.effectLeaseLocked()41 owner := moved.effectLeaseLocked()
41 moved.mu.Unlock()42 moved.mu.Unlock()
42 members := []resizeMember{{session: source, tab: sourceTab, pane: moved, owner: owner}}43 members := []resizeMember{{session: source, tab: sourceTab, pane: moved, owner: owner}}
44+ rc := source.renderCoordinator()
45+ require.NotNil(t, rc)
46+ lease := rc.attachmentLease(client)
47+ epoch := rc.recordResizeRequestForLease(client.size, client, lease)
48+ ticket, admitted := beginActiveLeaseEffect(source, client, lease)
49+ require.True(t, admitted)
50+ defer ticket.End()
43 51
44 sendLocked := make(chan struct{})52 sendLocked := make(chan struct{})
45 allowResizeFence := make(chan struct{})53 allowResizeFence := make(chan struct{})
@@ -50,7 +58,7 @@ func TestMovePaneReleasesResizeFencesBeforeAttachmentCleanup(t *testing.T) {
50 defer func() { d.afterResizeCommitSendLocked = nil }()58 defer func() { d.afterResizeCommitSendLocked = nil }()
51 resizeDone := make(chan bool, 1)59 resizeDone := make(chan bool, 1)
52 go func() {60 go func() {
53- resizeDone <- d.publishResizeCommit(members, source, client, nil, 0, nil, client.size)61+ resizeDone <- d.publishResizeCommit(members, source, client, lease, epoch, ticket, client.size)
54 }()62 }()
55 awaitTestCompletion(t, sendLocked, "resize commit did not acquire sendMu")63 awaitTestCompletion(t, sendLocked, "resize commit did not acquire sendMu")
56 64
M internal/usecase/daemon/move_gate_race_test.go +7 -3
@@ -1,6 +1,7 @@
11 package daemon
22
33 import (
4+ "sync"
45 "testing"
56 "time"
67
@@ -76,11 +77,12 @@ func TestMovePaneGateReservationRaceKillWinsWithoutDeadlock(t *testing.T) {
7677 }
7778 killFrozen := make(chan struct{})
7879 releaseKill := make(chan struct{})
80+ var killFrozenOnce sync.Once
7981 d.afterRoleEffectGateFrozen = func(action string, _ *attachedClient) {
8082 if action != "" {
8183 return
8284 }
83- close(killFrozen)
85+ killFrozenOnce.Do(func() { close(killFrozen) })
8486 <-releaseKill
8587 }
8688 defer func() {
@@ -119,17 +121,19 @@ func TestMovePaneGateReservationRaceMoveWinsWithoutDeadlock(t *testing.T) {
119121
120122 moveGateFrozen := make(chan struct{})
121123 releaseMove := make(chan struct{})
124+ var moveGateFrozenOnce sync.Once
122125 d.afterRoleEffectGateFrozen = func(action string, _ *attachedClient) {
123126 if action != "move-pane" {
124127 return
125128 }
126- close(moveGateFrozen)
129+ moveGateFrozenOnce.Do(func() { close(moveGateFrozen) })
127130 <-releaseMove
128131 }
129132 killSnapshotted := make(chan struct{})
133+ var killSnapshottedOnce sync.Once
130134 d.afterRoleEffectParticipantsSnapshotted = func(action string, _ []*attachedClient) {
131135 if action == "" {
132- close(killSnapshotted)
136+ killSnapshottedOnce.Do(func() { close(killSnapshotted) })
133137 }
134138 }
135139 defer func() {
@@ -1,6 +1,7 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "sync"
4 "testing"5 "testing"
5 "time"6 "time"
6 7
@@ -76,11 +77,12 @@ func TestMovePaneGateReservationRaceKillWinsWithoutDeadlock(t *testing.T) {
76 }77 }
77 killFrozen := make(chan struct{})78 killFrozen := make(chan struct{})
78 releaseKill := make(chan struct{})79 releaseKill := make(chan struct{})
80+ var killFrozenOnce sync.Once
79 d.afterRoleEffectGateFrozen = func(action string, _ *attachedClient) {81 d.afterRoleEffectGateFrozen = func(action string, _ *attachedClient) {
80 if action != "" {82 if action != "" {
81 return83 return
82 }84 }
83- close(killFrozen)85+ killFrozenOnce.Do(func() { close(killFrozen) })
84 <-releaseKill86 <-releaseKill
85 }87 }
86 defer func() {88 defer func() {
@@ -119,17 +121,19 @@ func TestMovePaneGateReservationRaceMoveWinsWithoutDeadlock(t *testing.T) {
119 121
120 moveGateFrozen := make(chan struct{})122 moveGateFrozen := make(chan struct{})
121 releaseMove := make(chan struct{})123 releaseMove := make(chan struct{})
124+ var moveGateFrozenOnce sync.Once
122 d.afterRoleEffectGateFrozen = func(action string, _ *attachedClient) {125 d.afterRoleEffectGateFrozen = func(action string, _ *attachedClient) {
123 if action != "move-pane" {126 if action != "move-pane" {
124 return127 return
125 }128 }
126- close(moveGateFrozen)129+ moveGateFrozenOnce.Do(func() { close(moveGateFrozen) })
127 <-releaseMove130 <-releaseMove
128 }131 }
129 killSnapshotted := make(chan struct{})132 killSnapshotted := make(chan struct{})
133+ var killSnapshottedOnce sync.Once
130 d.afterRoleEffectParticipantsSnapshotted = func(action string, _ []*attachedClient) {134 d.afterRoleEffectParticipantsSnapshotted = func(action string, _ []*attachedClient) {
131 if action == "" {135 if action == "" {
132- close(killSnapshotted)136+ killSnapshottedOnce.Do(func() { close(killSnapshotted) })
133 }137 }
134 }138 }
135 defer func() {139 defer func() {
M internal/usecase/daemon/move_handoff_failure_test.go +1 -1
@@ -294,7 +294,7 @@ func captureMoveTabHandoffState(d *Daemon, source, destination *session, moved *
294294 }
295295
296296 func TestMoveTabFinalSourceHandoffValidationFailureIsAtomic(t *testing.T) {
297- d, source, follower, destination, _, moved := setupMoveTabFinalSourceHandoff(t)
297+ d, source, follower, destination, _, _, moved := setupMoveTabFinalSourceHandoff(t)
298298 floating := newPaneWithStableID("floating", "floating-stable", newQuietPTY(), domain.Size{Cols: 20, Rows: 8})
299299 floating.ctx, floating.cancel = context.WithCancel(d.paneProcessCtx)
300300 t.Cleanup(floating.cancel)
@@ -294,7 +294,7 @@ func captureMoveTabHandoffState(d *Daemon, source, destination *session, moved *
294 }294 }
295 295
296 func TestMoveTabFinalSourceHandoffValidationFailureIsAtomic(t *testing.T) {296 func TestMoveTabFinalSourceHandoffValidationFailureIsAtomic(t *testing.T) {
297- d, source, follower, destination, _, moved := setupMoveTabFinalSourceHandoff(t)297+ d, source, follower, destination, _, _, moved := setupMoveTabFinalSourceHandoff(t)
298 floating := newPaneWithStableID("floating", "floating-stable", newQuietPTY(), domain.Size{Cols: 20, Rows: 8})298 floating := newPaneWithStableID("floating", "floating-stable", newQuietPTY(), domain.Size{Cols: 20, Rows: 8})
299 floating.ctx, floating.cancel = context.WithCancel(d.paneProcessCtx)299 floating.ctx, floating.cancel = context.WithCancel(d.paneProcessCtx)
300 t.Cleanup(floating.cancel)300 t.Cleanup(floating.cancel)
M internal/usecase/daemon/move_lifecycle.go +3 -0
@@ -41,6 +41,9 @@ func (d *Daemon) reserveMoveLifecycles(source, destination *session) (*moveLifec
4141 d.moveLifecycleActive++
4242 d.signalMoveLifecycleChangedLocked()
4343 d.moveLifecycleMu.Unlock()
44+ if d.afterMoveLifecycleGateBeforeTeardownLocks != nil {
45+ d.afterMoveLifecycleGateBeforeTeardownLocks()
46+ }
4447
4548 sessions := uniqueMoveSessions(source, destination)
4649 for _, sess := range sessions {
@@ -41,6 +41,9 @@ func (d *Daemon) reserveMoveLifecycles(source, destination *session) (*moveLifec
41 d.moveLifecycleActive++41 d.moveLifecycleActive++
42 d.signalMoveLifecycleChangedLocked()42 d.signalMoveLifecycleChangedLocked()
43 d.moveLifecycleMu.Unlock()43 d.moveLifecycleMu.Unlock()
44+ if d.afterMoveLifecycleGateBeforeTeardownLocks != nil {
45+ d.afterMoveLifecycleGateBeforeTeardownLocks()
46+ }
44 47
45 sessions := uniqueMoveSessions(source, destination)48 sessions := uniqueMoveSessions(source, destination)
46 for _, sess := range sessions {49 for _, sess := range sessions {
M internal/usecase/daemon/move_lifecycle_test.go +6 -0
@@ -94,6 +94,9 @@ func TestMoveLifecycleReservationsUseStableSessionOrder(t *testing.T) {
9494 // Both opposite-direction reservations must block on a before either takes
9595 // b. This proves order from immutable IDs rather than argument direction.
9696 first.teardownMu.Lock()
97+ beforeTeardownLocks := make(chan struct{}, 2)
98+ d.afterMoveLifecycleGateBeforeTeardownLocks = func() { beforeTeardownLocks <- struct{}{} }
99+ defer func() { d.afterMoveLifecycleGateBeforeTeardownLocks = nil }()
97100 results := make(chan *moveLifecycleReservation, 2)
98101 errs := make(chan error, 2)
99102 for _, pair := range [][2]*session{{first, second}, {second, first}} {
@@ -104,6 +107,9 @@ func TestMoveLifecycleReservationsUseStableSessionOrder(t *testing.T) {
104107 }()
105108 }
106109 awaitDaemonMoveActive(t, d, 2)
110+ for range 2 {
111+ awaitTestCompletion(t, beforeTeardownLocks, "move did not reach ordered teardown-lock admission")
112+ }
107113 require.True(t, second.teardownMu.TryLock(), "opposite move locked b before the shared lowest-ID session")
108114 second.teardownMu.Unlock()
109115 first.teardownMu.Unlock()
@@ -94,6 +94,9 @@ func TestMoveLifecycleReservationsUseStableSessionOrder(t *testing.T) {
94 // Both opposite-direction reservations must block on a before either takes94 // Both opposite-direction reservations must block on a before either takes
95 // b. This proves order from immutable IDs rather than argument direction.95 // b. This proves order from immutable IDs rather than argument direction.
96 first.teardownMu.Lock()96 first.teardownMu.Lock()
97+ beforeTeardownLocks := make(chan struct{}, 2)
98+ d.afterMoveLifecycleGateBeforeTeardownLocks = func() { beforeTeardownLocks <- struct{}{} }
99+ defer func() { d.afterMoveLifecycleGateBeforeTeardownLocks = nil }()
97 results := make(chan *moveLifecycleReservation, 2)100 results := make(chan *moveLifecycleReservation, 2)
98 errs := make(chan error, 2)101 errs := make(chan error, 2)
99 for _, pair := range [][2]*session{{first, second}, {second, first}} {102 for _, pair := range [][2]*session{{first, second}, {second, first}} {
@@ -104,6 +107,9 @@ func TestMoveLifecycleReservationsUseStableSessionOrder(t *testing.T) {
104 }()107 }()
105 }108 }
106 awaitDaemonMoveActive(t, d, 2)109 awaitDaemonMoveActive(t, d, 2)
110+ for range 2 {
111+ awaitTestCompletion(t, beforeTeardownLocks, "move did not reach ordered teardown-lock admission")
112+ }
107 require.True(t, second.teardownMu.TryLock(), "opposite move locked b before the shared lowest-ID session")113 require.True(t, second.teardownMu.TryLock(), "opposite move locked b before the shared lowest-ID session")
108 second.teardownMu.Unlock()114 second.teardownMu.Unlock()
109 first.teardownMu.Unlock()115 first.teardownMu.Unlock()
M internal/usecase/daemon/move_pane_candidate.go +6 -3
@@ -93,12 +93,15 @@ func allocateMovePaneID(destination *tab, sourceID layout.PaneID) (layout.PaneID
9393 id := layout.PaneID(fmt.Sprintf("pane-%d", next))
9494 _, mapped := destination.panes[id]
9595 available := id != sourceID && !mapped && !layout.ContainsLeaf(destination.tree.Root, id)
96- if next == math.MaxInt {
97- return "", 0, errMovePaneIDExhausted
98- }
9996 if available {
97+ if next == math.MaxInt {
98+ return id, next, nil
99+ }
100100 return id, next + 1, nil
101101 }
102+ if next == math.MaxInt {
103+ return "", 0, errMovePaneIDExhausted
104+ }
102105 next++
103106 }
104107 }
@@ -93,12 +93,15 @@ func allocateMovePaneID(destination *tab, sourceID layout.PaneID) (layout.PaneID
93 id := layout.PaneID(fmt.Sprintf("pane-%d", next))93 id := layout.PaneID(fmt.Sprintf("pane-%d", next))
94 _, mapped := destination.panes[id]94 _, mapped := destination.panes[id]
95 available := id != sourceID && !mapped && !layout.ContainsLeaf(destination.tree.Root, id)95 available := id != sourceID && !mapped && !layout.ContainsLeaf(destination.tree.Root, id)
96- if next == math.MaxInt {
97- return "", 0, errMovePaneIDExhausted
98- }
99 if available {96 if available {
97+ if next == math.MaxInt {
98+ return id, next, nil
99+ }
100 return id, next + 1, nil100 return id, next + 1, nil
101 }101 }
102+ if next == math.MaxInt {
103+ return "", 0, errMovePaneIDExhausted
104+ }
102 next++105 next++
103 }106 }
104 }107 }
M internal/usecase/daemon/move_pane_postcommit.go +25 -7
@@ -1,6 +1,7 @@
11 package daemon
22
33 import (
4+ "context"
45 "errors"
56 "os"
67
@@ -11,12 +12,20 @@ import (
1112 // movePanePostcommitPlan is captured while the move's architecture and owner
1213 // fences still protect publication. It contains only immutable pointers,
1314 // generations, and exact attachment tokens needed by fallible follow-up work.
15+func firstMovePane(panes []*pane) *pane {
16+ if len(panes) == 0 {
17+ return nil
18+ }
19+ return panes[0]
20+}
21+
1422 type movePanePostcommitPlan struct {
1523 source *session
1624 destination *session
1725 sourceTab *tab
1826 destinationTab *tab
1927 movedPane *pane
28+ movedPanes []*pane
2029 destinationClient *attachedClient
2130 sourceCleanupToken attachmentRoleToken
2231 handoffResult attachmentTransitionResult
@@ -25,6 +34,7 @@ type movePanePostcommitPlan struct {
2534 rolesFrozen bool
2635 unlockDispatch func()
2736 reservation *moveLifecycleReservation
37+ oldTabCancel context.CancelFunc
2838
2939 sourceTabWasActive bool
3040 sourceTabRemoved bool
@@ -42,15 +52,23 @@ type movePanePostcommitPlan struct {
4252 // released. Generation-bound tokens make stale effects drop rather than route
4353 // back to the retired source.
4454 func (p movePanePostcommitPlan) execute(d *Daemon) {
45- if p.sourceTabRemoved && p.sourceTab.cancel != nil {
55+ if p.oldTabCancel != nil {
56+ p.oldTabCancel()
57+ } else if p.sourceTabRemoved && p.sourceTab != nil && p.sourceTab.cancel != nil {
4658 p.sourceTab.cancel()
4759 }
4860 if p.sourceCleanupToken.current() {
4961 cleanupClient := p.sourceCleanupToken.ac
50- if cleanupClient.overlays != nil {
51- cleanupClient.overlays.clearCopyModeForPane(p.movedPane)
62+ panes := p.movedPanes
63+ if len(panes) == 0 && p.movedPane != nil {
64+ panes = []*pane{p.movedPane}
65+ }
66+ for _, movedPane := range panes {
67+ if cleanupClient.overlays != nil {
68+ cleanupClient.overlays.clearCopyModeForPane(movedPane)
69+ }
70+ cleanupClient.pruneCaptureFrames(movedPane)
5271 }
53- cleanupClient.pruneCaptureFrames(p.movedPane)
5472 }
5573
5674 // Snapshot dirtiness is admitted while lifecycle reservations still pin
@@ -66,10 +84,10 @@ func (p movePanePostcommitPlan) execute(d *Daemon) {
6684 p.unlockDispatch()
6785 p.reservation.Release()
6886
69- if p.sourceTabWasActive && !moveSessionStillEmpty(d, p.source) {
87+ if p.sourceTabWasActive && !moveSessionRetired(d, p.source) {
7088 d.activateTab(p.source, p.source.activeTab())
7189 }
72- if !moveSessionStillEmpty(d, p.source) {
90+ if !moveSessionRetired(d, p.source) {
7391 sourceLayoutTab := p.sourceTab
7492 if p.sourceTabRemoved {
7593 sourceLayoutTab = p.source.activeTab()
@@ -144,7 +162,7 @@ func retireEmptyMoveSessionLocked(sess *session, retirement frozenMoveAttachment
144162 return retired
145163 }
146164
147-func moveSessionStillEmpty(d *Daemon, sess *session) bool {
165+func moveSessionRetired(d *Daemon, sess *session) bool {
148166 if d == nil || sess == nil {
149167 return true
150168 }
@@ -1,6 +1,7 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "context"
4 "errors"5 "errors"
5 "os"6 "os"
6 7
@@ -11,12 +12,20 @@ import (
11 // movePanePostcommitPlan is captured while the move's architecture and owner12 // movePanePostcommitPlan is captured while the move's architecture and owner
12 // fences still protect publication. It contains only immutable pointers,13 // fences still protect publication. It contains only immutable pointers,
13 // generations, and exact attachment tokens needed by fallible follow-up work.14 // generations, and exact attachment tokens needed by fallible follow-up work.
15+func firstMovePane(panes []*pane) *pane {
16+ if len(panes) == 0 {
17+ return nil
18+ }
19+ return panes[0]
20+}
21+
14 type movePanePostcommitPlan struct {22 type movePanePostcommitPlan struct {
15 source *session23 source *session
16 destination *session24 destination *session
17 sourceTab *tab25 sourceTab *tab
18 destinationTab *tab26 destinationTab *tab
19 movedPane *pane27 movedPane *pane
28+ movedPanes []*pane
20 destinationClient *attachedClient29 destinationClient *attachedClient
21 sourceCleanupToken attachmentRoleToken30 sourceCleanupToken attachmentRoleToken
22 handoffResult attachmentTransitionResult31 handoffResult attachmentTransitionResult
@@ -25,6 +34,7 @@ type movePanePostcommitPlan struct {
25 rolesFrozen bool34 rolesFrozen bool
26 unlockDispatch func()35 unlockDispatch func()
27 reservation *moveLifecycleReservation36 reservation *moveLifecycleReservation
37+ oldTabCancel context.CancelFunc
28 38
29 sourceTabWasActive bool39 sourceTabWasActive bool
30 sourceTabRemoved bool40 sourceTabRemoved bool
@@ -42,15 +52,23 @@ type movePanePostcommitPlan struct {
42 // released. Generation-bound tokens make stale effects drop rather than route52 // released. Generation-bound tokens make stale effects drop rather than route
43 // back to the retired source.53 // back to the retired source.
44 func (p movePanePostcommitPlan) execute(d *Daemon) {54 func (p movePanePostcommitPlan) execute(d *Daemon) {
45- if p.sourceTabRemoved && p.sourceTab.cancel != nil {55+ if p.oldTabCancel != nil {
56+ p.oldTabCancel()
57+ } else if p.sourceTabRemoved && p.sourceTab != nil && p.sourceTab.cancel != nil {
46 p.sourceTab.cancel()58 p.sourceTab.cancel()
47 }59 }
48 if p.sourceCleanupToken.current() {60 if p.sourceCleanupToken.current() {
49 cleanupClient := p.sourceCleanupToken.ac61 cleanupClient := p.sourceCleanupToken.ac
50- if cleanupClient.overlays != nil {62+ panes := p.movedPanes
51- cleanupClient.overlays.clearCopyModeForPane(p.movedPane)63+ if len(panes) == 0 && p.movedPane != nil {
64+ panes = []*pane{p.movedPane}
65+ }
66+ for _, movedPane := range panes {
67+ if cleanupClient.overlays != nil {
68+ cleanupClient.overlays.clearCopyModeForPane(movedPane)
69+ }
70+ cleanupClient.pruneCaptureFrames(movedPane)
52 }71 }
53- cleanupClient.pruneCaptureFrames(p.movedPane)
54 }72 }
55 73
56 // Snapshot dirtiness is admitted while lifecycle reservations still pin74 // Snapshot dirtiness is admitted while lifecycle reservations still pin
@@ -66,10 +84,10 @@ func (p movePanePostcommitPlan) execute(d *Daemon) {
66 p.unlockDispatch()84 p.unlockDispatch()
67 p.reservation.Release()85 p.reservation.Release()
68 86
69- if p.sourceTabWasActive && !moveSessionStillEmpty(d, p.source) {87+ if p.sourceTabWasActive && !moveSessionRetired(d, p.source) {
70 d.activateTab(p.source, p.source.activeTab())88 d.activateTab(p.source, p.source.activeTab())
71 }89 }
72- if !moveSessionStillEmpty(d, p.source) {90+ if !moveSessionRetired(d, p.source) {
73 sourceLayoutTab := p.sourceTab91 sourceLayoutTab := p.sourceTab
74 if p.sourceTabRemoved {92 if p.sourceTabRemoved {
75 sourceLayoutTab = p.source.activeTab()93 sourceLayoutTab = p.source.activeTab()
@@ -144,7 +162,7 @@ func retireEmptyMoveSessionLocked(sess *session, retirement frozenMoveAttachment
144 return retired162 return retired
145 }163 }
146 164
147-func moveSessionStillEmpty(d *Daemon, sess *session) bool {165+func moveSessionRetired(d *Daemon, sess *session) bool {
148 if d == nil || sess == nil {166 if d == nil || sess == nil {
149 return true167 return true
150 }168 }
M internal/usecase/daemon/move_persistence_test.go +43 -10
@@ -79,18 +79,51 @@ type movePersistenceRepository struct {
7979 func (r *movePersistenceRepository) Publish(context.Context, ports.SnapshotPublication) error {
8080 outsideLocks := true
8181 unlocks := make([]func(), 0, 7)
82- for _, candidate := range []struct {
82+ candidates := make([]struct {
8383 tryLock func() bool
8484 unlock func()
85- }{
86- {r.daemon.mu.TryLock, r.daemon.mu.Unlock},
87- {r.source.mu.TryLock, r.source.mu.Unlock},
88- {r.destination.mu.TryLock, r.destination.mu.Unlock},
89- {r.moved.mu.TryLock, r.moved.mu.Unlock},
90- {r.pane.mu.TryLock, r.pane.mu.Unlock},
91- {r.source.layoutApplyMu.TryLock, r.source.layoutApplyMu.Unlock},
92- {r.destination.layoutApplyMu.TryLock, r.destination.layoutApplyMu.Unlock},
93- } {
85+ }, 0, 7)
86+ if r.daemon != nil {
87+ candidates = append(candidates, struct {
88+ tryLock func() bool
89+ unlock func()
90+ }{r.daemon.mu.TryLock, r.daemon.mu.Unlock})
91+ }
92+ if r.source != nil {
93+ candidates = append(candidates,
94+ struct {
95+ tryLock func() bool
96+ unlock func()
97+ }{r.source.mu.TryLock, r.source.mu.Unlock},
98+ struct {
99+ tryLock func() bool
100+ unlock func()
101+ }{r.source.layoutApplyMu.TryLock, r.source.layoutApplyMu.Unlock})
102+ }
103+ if r.destination != nil {
104+ candidates = append(candidates,
105+ struct {
106+ tryLock func() bool
107+ unlock func()
108+ }{r.destination.mu.TryLock, r.destination.mu.Unlock},
109+ struct {
110+ tryLock func() bool
111+ unlock func()
112+ }{r.destination.layoutApplyMu.TryLock, r.destination.layoutApplyMu.Unlock})
113+ }
114+ if r.moved != nil {
115+ candidates = append(candidates, struct {
116+ tryLock func() bool
117+ unlock func()
118+ }{r.moved.mu.TryLock, r.moved.mu.Unlock})
119+ }
120+ if r.pane != nil {
121+ candidates = append(candidates, struct {
122+ tryLock func() bool
123+ unlock func()
124+ }{r.pane.mu.TryLock, r.pane.mu.Unlock})
125+ }
126+ for _, candidate := range candidates {
94127 if candidate.tryLock() {
95128 unlocks = append(unlocks, candidate.unlock)
96129 } else {
@@ -79,18 +79,51 @@ type movePersistenceRepository struct {
79 func (r *movePersistenceRepository) Publish(context.Context, ports.SnapshotPublication) error {79 func (r *movePersistenceRepository) Publish(context.Context, ports.SnapshotPublication) error {
80 outsideLocks := true80 outsideLocks := true
81 unlocks := make([]func(), 0, 7)81 unlocks := make([]func(), 0, 7)
82- for _, candidate := range []struct {82+ candidates := make([]struct {
83 tryLock func() bool83 tryLock func() bool
84 unlock func()84 unlock func()
85- }{85+ }, 0, 7)
86- {r.daemon.mu.TryLock, r.daemon.mu.Unlock},86+ if r.daemon != nil {
87- {r.source.mu.TryLock, r.source.mu.Unlock},87+ candidates = append(candidates, struct {
88- {r.destination.mu.TryLock, r.destination.mu.Unlock},88+ tryLock func() bool
89- {r.moved.mu.TryLock, r.moved.mu.Unlock},89+ unlock func()
90- {r.pane.mu.TryLock, r.pane.mu.Unlock},90+ }{r.daemon.mu.TryLock, r.daemon.mu.Unlock})
91- {r.source.layoutApplyMu.TryLock, r.source.layoutApplyMu.Unlock},91+ }
92- {r.destination.layoutApplyMu.TryLock, r.destination.layoutApplyMu.Unlock},92+ if r.source != nil {
93- } {93+ candidates = append(candidates,
94+ struct {
95+ tryLock func() bool
96+ unlock func()
97+ }{r.source.mu.TryLock, r.source.mu.Unlock},
98+ struct {
99+ tryLock func() bool
100+ unlock func()
101+ }{r.source.layoutApplyMu.TryLock, r.source.layoutApplyMu.Unlock})
102+ }
103+ if r.destination != nil {
104+ candidates = append(candidates,
105+ struct {
106+ tryLock func() bool
107+ unlock func()
108+ }{r.destination.mu.TryLock, r.destination.mu.Unlock},
109+ struct {
110+ tryLock func() bool
111+ unlock func()
112+ }{r.destination.layoutApplyMu.TryLock, r.destination.layoutApplyMu.Unlock})
113+ }
114+ if r.moved != nil {
115+ candidates = append(candidates, struct {
116+ tryLock func() bool
117+ unlock func()
118+ }{r.moved.mu.TryLock, r.moved.mu.Unlock})
119+ }
120+ if r.pane != nil {
121+ candidates = append(candidates, struct {
122+ tryLock func() bool
123+ unlock func()
124+ }{r.pane.mu.TryLock, r.pane.mu.Unlock})
125+ }
126+ for _, candidate := range candidates {
94 if candidate.tryLock() {127 if candidate.tryLock() {
95 unlocks = append(unlocks, candidate.unlock)128 unlocks = append(unlocks, candidate.unlock)
96 } else {129 } else {
M internal/usecase/daemon/move_rejection_test.go +1 -1
@@ -89,7 +89,7 @@ func TestMoveRejectionPresentationParity(t *testing.T) {
8989 require.False(t, result.OK)
9090 require.Equal(t, descriptor.CommandCode, result.Code)
9191 require.Equal(t, descriptor.CommandText, result.Text)
92- require.Equal(t, uint16(ports.ErrNoSuchTarget), result.Code)
92+ require.Equal(t, ports.ErrNoSuchTarget, result.Code)
9393 require.Equal(t, tt.commandText, result.Text)
9494 })
9595 }
@@ -89,7 +89,7 @@ func TestMoveRejectionPresentationParity(t *testing.T) {
89 require.False(t, result.OK)89 require.False(t, result.OK)
90 require.Equal(t, descriptor.CommandCode, result.Code)90 require.Equal(t, descriptor.CommandCode, result.Code)
91 require.Equal(t, descriptor.CommandText, result.Text)91 require.Equal(t, descriptor.CommandText, result.Text)
92- require.Equal(t, uint16(ports.ErrNoSuchTarget), result.Code)92+ require.Equal(t, ports.ErrNoSuchTarget, result.Code)
93 require.Equal(t, tt.commandText, result.Text)93 require.Equal(t, tt.commandText, result.Text)
94 })94 })
95 }95 }
M internal/usecase/daemon/move_resize_fences.go +4 -0
@@ -177,6 +177,10 @@ func (d *Daemon) publishResizeOwnerPostEffect(members []resizeMember, effect res
177177 return false
178178 }
179179 d.observeBeforeResizeOwnerPostEffect(effect)
180+ if len(members) == 0 {
181+ publish()
182+ return true
183+ }
180184 fences := acquireResizeOwnerPostEffectFences(members)
181185 if fences == nil {
182186 return false
@@ -177,6 +177,10 @@ func (d *Daemon) publishResizeOwnerPostEffect(members []resizeMember, effect res
177 return false177 return false
178 }178 }
179 d.observeBeforeResizeOwnerPostEffect(effect)179 d.observeBeforeResizeOwnerPostEffect(effect)
180+ if len(members) == 0 {
181+ publish()
182+ return true
183+ }
180 fences := acquireResizeOwnerPostEffectFences(members)184 fences := acquireResizeOwnerPostEffectFences(members)
181 if fences == nil {185 if fences == nil {
182 return false186 return false
M internal/usecase/daemon/move_stale_destination_test.go +14 -3
@@ -28,8 +28,12 @@ func TestMovePaneRejectsStaleDestinationIncarnationWithoutMutation(t *testing.T)
2828 d.sessions[destination.id] = destination
2929 d.mu.Unlock()
3030
31+ sourceTab.mu.Lock()
3132 beforeSource := sourceTab.tree.Clone()
33+ sourceTab.mu.Unlock()
34+ destinationTab.mu.Lock()
3235 beforeDestination := destinationTab.tree.Clone()
36+ destinationTab.mu.Unlock()
3337 beforeOwner := moved.ownerSnapshot()
3438 staleIncarnation := destination.incarnation
3539 staleIncarnation[0]++
@@ -41,8 +45,15 @@ func TestMovePaneRejectsStaleDestinationIncarnationWithoutMutation(t *testing.T)
4145 DestinationTabID: domain.TabStableID(destinationTab.stableID),
4246 })
4347 require.ErrorIs(t, err, errMoveStaleTarget)
44- require.Equal(t, beforeSource, sourceTab.tree)
45- require.Equal(t, beforeDestination, destinationTab.tree)
48+ sourceTab.mu.Lock()
49+ sourceAfter := sourceTab.tree.Clone()
50+ sourceMoved := sourceTab.panes[moved.id]
51+ sourceTab.mu.Unlock()
52+ destinationTab.mu.Lock()
53+ destinationAfter := destinationTab.tree.Clone()
54+ destinationTab.mu.Unlock()
55+ require.Equal(t, beforeSource, sourceAfter)
56+ require.Equal(t, beforeDestination, destinationAfter)
4657 require.Same(t, beforeOwner, moved.ownerSnapshot())
47- require.Same(t, moved, sourceTab.panes[moved.id])
58+ require.Same(t, moved, sourceMoved)
4859 }
@@ -28,8 +28,12 @@ func TestMovePaneRejectsStaleDestinationIncarnationWithoutMutation(t *testing.T)
28 d.sessions[destination.id] = destination28 d.sessions[destination.id] = destination
29 d.mu.Unlock()29 d.mu.Unlock()
30 30
31+ sourceTab.mu.Lock()
31 beforeSource := sourceTab.tree.Clone()32 beforeSource := sourceTab.tree.Clone()
33+ sourceTab.mu.Unlock()
34+ destinationTab.mu.Lock()
32 beforeDestination := destinationTab.tree.Clone()35 beforeDestination := destinationTab.tree.Clone()
36+ destinationTab.mu.Unlock()
33 beforeOwner := moved.ownerSnapshot()37 beforeOwner := moved.ownerSnapshot()
34 staleIncarnation := destination.incarnation38 staleIncarnation := destination.incarnation
35 staleIncarnation[0]++39 staleIncarnation[0]++
@@ -41,8 +45,15 @@ func TestMovePaneRejectsStaleDestinationIncarnationWithoutMutation(t *testing.T)
41 DestinationTabID: domain.TabStableID(destinationTab.stableID),45 DestinationTabID: domain.TabStableID(destinationTab.stableID),
42 })46 })
43 require.ErrorIs(t, err, errMoveStaleTarget)47 require.ErrorIs(t, err, errMoveStaleTarget)
44- require.Equal(t, beforeSource, sourceTab.tree)48+ sourceTab.mu.Lock()
45- require.Equal(t, beforeDestination, destinationTab.tree)49+ sourceAfter := sourceTab.tree.Clone()
50+ sourceMoved := sourceTab.panes[moved.id]
51+ sourceTab.mu.Unlock()
52+ destinationTab.mu.Lock()
53+ destinationAfter := destinationTab.tree.Clone()
54+ destinationTab.mu.Unlock()
55+ require.Equal(t, beforeSource, sourceAfter)
56+ require.Equal(t, beforeDestination, destinationAfter)
46 require.Same(t, beforeOwner, moved.ownerSnapshot())57 require.Same(t, beforeOwner, moved.ownerSnapshot())
47- require.Same(t, moved, sourceTab.panes[moved.id])58+ require.Same(t, moved, sourceMoved)
48 }59 }
M internal/usecase/daemon/move_tab.go +58 -89
@@ -4,7 +4,6 @@ import (
44 "context"
55
66 "github.com/bnema/vev/internal/domain"
7- "github.com/bnema/vev/internal/ports"
87 "github.com/bnema/vev/internal/usecase/layout"
98 )
109
@@ -47,6 +46,27 @@ type moveTabCommit struct {
4746 err error
4847 }
4948
49+func (d *Daemon) snapshotSnatchedRoleParticipants(sess *session, admitted []*attachedClient) ([]*attachedClient, []roleTransportInterrupt) {
50+ if sess == nil || len(admitted) == 0 {
51+ return nil, nil
52+ }
53+ d.mu.Lock()
54+ defer d.mu.Unlock()
55+ sess.mu.Lock()
56+ defer sess.mu.Unlock()
57+ clients := append([]*attachedClient(nil), admitted...)
58+ interrupts := make([]roleTransportInterrupt, 0, len(clients))
59+ for _, ac := range clients {
60+ if ac == nil {
61+ continue
62+ }
63+ if transport := ac.transportSnapshot(); transport.transport != nil {
64+ interrupts = append(interrupts, roleTransportInterrupt{ac: ac, transport: transport})
65+ }
66+ }
67+ return clients, interrupts
68+}
69+
5070 func (d *Daemon) moveTab(req moveTabRequest) (result error) {
5171 defer func() { result = normalizeMoveRejection(result) }()
5272 if d == nil || req.Source.ID == "" || req.Destination.ID == "" || req.SourceTabID == "" || req.Source.ID == req.Destination.ID {
@@ -109,21 +129,14 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) {
109129 if err != nil {
110130 return err
111131 }
112- d.mu.Lock()
113- source.mu.Lock()
114- for _, ac := range admission.sourceSnatched {
115- participants.clients = append(participants.clients, ac)
116- if tr := ac.transportSnapshot(); tr.transport != nil {
117- participants.interrupts = append(participants.interrupts, roleTransportInterrupt{ac: ac, transport: tr})
118- }
119- }
120- source.mu.Unlock()
121- d.mu.Unlock()
122- frozen = tryFreezeRoleEffectGatesInterruptingObserved(participants.interrupts, func(ac *attachedClient) {
132+ snatchedClients, snatchedInterrupts := d.snapshotSnatchedRoleParticipants(source, admission.sourceSnatched)
133+ participants.clients = append(participants.clients, snatchedClients...)
134+ participants.interrupts = append(participants.interrupts, snatchedInterrupts...)
135+ frozen = freezeRoleEffectGatesWith(roleEffectFreezeOptions{interrupts: participants.interrupts, nonblocking: true, afterFrozen: func(ac *attachedClient) {
123136 if d.afterRoleEffectGateFrozen != nil {
124137 d.afterRoleEffectGateFrozen("move-tab", ac)
125138 }
126- }, participants.clients...)
139+ }}, participants.clients...)
127140 if !frozen.acquired || !frozen.drained {
128141 frozen.unfreeze()
129142 return errMovePaneInvalid
@@ -132,18 +145,8 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) {
132145 commit.sourceRolesFrozen = true
133146 commit.handoffReq.roleEffectsFrozen = true
134147 } else if admission.finalSource && len(admission.sourceSnatched) > 0 {
135- d.mu.Lock()
136- source.mu.Lock()
137- clients := append([]*attachedClient(nil), admission.sourceSnatched...)
138- interrupts := make([]roleTransportInterrupt, 0, len(admission.sourceSnatched))
139- for _, ac := range admission.sourceSnatched {
140- if tr := ac.transportSnapshot(); tr.transport != nil {
141- interrupts = append(interrupts, roleTransportInterrupt{ac: ac, transport: tr})
142- }
143- }
144- source.mu.Unlock()
145- d.mu.Unlock()
146- frozen = tryFreezeRoleEffectGatesInterruptingObserved(interrupts, nil, clients...)
148+ clients, interrupts := d.snapshotSnatchedRoleParticipants(source, admission.sourceSnatched)
149+ frozen = freezeRoleEffectGatesWith(roleEffectFreezeOptions{interrupts: interrupts, nonblocking: true}, clients...)
147150 if !frozen.acquired || !frozen.drained {
148151 frozen.unfreeze()
149152 return errMovePaneInvalid
@@ -167,72 +170,35 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) {
167170 return errMoveStaleTarget
168171 }
169172 fences.Release()
170- if commit.oldTabCancel != nil {
171- commit.oldTabCancel()
172- }
173- for _, p := range admission.panes {
174- if commit.sourceCleanupToken.current() {
175- commit.sourceCleanupToken.ac.overlays.clearCopyModeForPane(p)
176- commit.sourceCleanupToken.ac.pruneCaptureFrames(p)
177- }
178- }
179- markSnapshotDirty(destination)
180- if !commit.sourceEmpty {
181- markSnapshotDirty(source)
182- }
183- commit.syncCleanup.finish()
184- if rolesFrozen {
185- frozen.unfreeze()
186- rolesFrozen = false
187- }
188- unlockDispatch()
173+ postcommit := movePanePostcommitPlan{
174+ source: source,
175+ destination: destination,
176+ sourceTab: admission.tab,
177+ destinationTab: admission.tab,
178+ movedPane: firstMovePane(admission.panes),
179+ movedPanes: admission.panes,
180+ sourceCleanupToken: commit.sourceCleanupToken,
181+ handoffResult: commit.handoffResult,
182+ syncCleanup: commit.syncCleanup,
183+ frozenRoles: frozen,
184+ rolesFrozen: rolesFrozen,
185+ unlockDispatch: unlockDispatch,
186+ reservation: reservation,
187+ oldTabCancel: commit.oldTabCancel,
188+ sourceTabWasActive: !commit.sourceEmpty,
189+ sourceTabRemoved: true,
190+ sourceEmpty: commit.sourceEmpty,
191+ retiredParked: commit.retiredParked,
192+ retiredAttachments: commit.retiredAttachments,
193+ sourceMetadata: commit.sourceMetadata,
194+ sourceMetadataValid: commit.sourceMetadataValid,
195+ destinationMetadata: commit.destMetadata,
196+ destinationMetadataValid: commit.destMetadataValid,
197+ }
198+ rolesFrozen = false
189199 dispatchHeld = false
190- reservation.Release()
191200 reservationHeld = false
192-
193- if !commit.sourceEmpty {
194- if sourceTab := source.activeTab(); sourceTab != nil {
195- d.activateTab(source, sourceTab)
196- d.applyTabLayout(source, sourceTab)
197- }
198- }
199- d.applyTabLayout(destination, admission.tab)
200- if commit.sourceMetadataValid && !commit.sourceEmpty {
201- d.markCatalogueDirty(commit.sourceMetadata)
202- }
203- if commit.destMetadataValid {
204- d.markCatalogueDirty(commit.destMetadata)
205- }
206- if commit.handoffResult.published.ac != nil {
207- follower := commit.handoffResult.published.ac
208- d.applyHostTheme(destination, follower, follower.getClientTheme(), false)
209- follower.recordPreviousSession(source)
210- d.deferAttachmentTransitionCleanups(commit.handoffResult)
211- d.firstPaintForTransition(commit.handoffResult.published)
212- }
213- if commit.sourceEmpty {
214- retireEmptySessionAfterMove(d, source)
215- }
216- for _, attachment := range commit.retiredAttachments {
217- d.unregisterPreview(attachment.ac)
218- attachment.ac.clearPreviousSession()
219- attachment.ac.clearCaptureFrames()
220- d.notifyDetachedSnapshotAsync(attachment, ports.ReasonSessionKilled)
221- }
222- for _, retirement := range commit.retiredParked {
223- if retirement.parked != nil && retirement.parked.ac != nil {
224- d.unregisterPreview(retirement.parked.ac)
225- retirement.parked.ac.clearCaptureFrames()
226- }
227- }
228- finishParkedAttachmentRetirements(commit.retiredParked)
229- if commit.sourceEmpty && commit.sourceMetadataValid && d.persistEnabled {
230- if err := d.beginSnapshotPurge(source.name, source.incarnation); err == nil {
231- if err := d.finishSnapshotPurge(d.serveCtx, source.name, source.incarnation); err != nil {
232- d.log.Warn("moving final tab source purge failed", "err", err, "session", source.name)
233- }
234- }
235- }
201+ postcommit.execute(d)
236202 return nil
237203 }
238204
@@ -260,6 +226,9 @@ func (d *Daemon) snapshotMoveTabAdmission(req moveTabRequest, source, destinatio
260226 if !moved.floatingTransferableLocked() {
261227 return nil, errMoveFloatingWarming
262228 }
229+ if moved.tree == nil || moved.tree.Root == nil {
230+ return nil, errMovePaneInvalid
231+ }
263232 if _, ok := layout.Solve(moved.tree.Root, domain.Rect{Width: destinationSize.Cols, Height: destinationSize.Rows}); !ok {
264233 return nil, errMoveTooSmall
265234 }
@@ -4,7 +4,6 @@ import (
4 "context"4 "context"
5 5
6 "github.com/bnema/vev/internal/domain"6 "github.com/bnema/vev/internal/domain"
7- "github.com/bnema/vev/internal/ports"
8 "github.com/bnema/vev/internal/usecase/layout"7 "github.com/bnema/vev/internal/usecase/layout"
9 )8 )
10 9
@@ -47,6 +46,27 @@ type moveTabCommit struct {
47 err error46 err error
48 }47 }
49 48
49+func (d *Daemon) snapshotSnatchedRoleParticipants(sess *session, admitted []*attachedClient) ([]*attachedClient, []roleTransportInterrupt) {
50+ if sess == nil || len(admitted) == 0 {
51+ return nil, nil
52+ }
53+ d.mu.Lock()
54+ defer d.mu.Unlock()
55+ sess.mu.Lock()
56+ defer sess.mu.Unlock()
57+ clients := append([]*attachedClient(nil), admitted...)
58+ interrupts := make([]roleTransportInterrupt, 0, len(clients))
59+ for _, ac := range clients {
60+ if ac == nil {
61+ continue
62+ }
63+ if transport := ac.transportSnapshot(); transport.transport != nil {
64+ interrupts = append(interrupts, roleTransportInterrupt{ac: ac, transport: transport})
65+ }
66+ }
67+ return clients, interrupts
68+}
69+
50 func (d *Daemon) moveTab(req moveTabRequest) (result error) {70 func (d *Daemon) moveTab(req moveTabRequest) (result error) {
51 defer func() { result = normalizeMoveRejection(result) }()71 defer func() { result = normalizeMoveRejection(result) }()
52 if d == nil || req.Source.ID == "" || req.Destination.ID == "" || req.SourceTabID == "" || req.Source.ID == req.Destination.ID {72 if d == nil || req.Source.ID == "" || req.Destination.ID == "" || req.SourceTabID == "" || req.Source.ID == req.Destination.ID {
@@ -109,21 +129,14 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) {
109 if err != nil {129 if err != nil {
110 return err130 return err
111 }131 }
112- d.mu.Lock()132+ snatchedClients, snatchedInterrupts := d.snapshotSnatchedRoleParticipants(source, admission.sourceSnatched)
113- source.mu.Lock()133+ participants.clients = append(participants.clients, snatchedClients...)
114- for _, ac := range admission.sourceSnatched {134+ participants.interrupts = append(participants.interrupts, snatchedInterrupts...)
115- participants.clients = append(participants.clients, ac)135+ frozen = freezeRoleEffectGatesWith(roleEffectFreezeOptions{interrupts: participants.interrupts, nonblocking: true, afterFrozen: func(ac *attachedClient) {
116- if tr := ac.transportSnapshot(); tr.transport != nil {
117- participants.interrupts = append(participants.interrupts, roleTransportInterrupt{ac: ac, transport: tr})
118- }
119- }
120- source.mu.Unlock()
121- d.mu.Unlock()
122- frozen = tryFreezeRoleEffectGatesInterruptingObserved(participants.interrupts, func(ac *attachedClient) {
123 if d.afterRoleEffectGateFrozen != nil {136 if d.afterRoleEffectGateFrozen != nil {
124 d.afterRoleEffectGateFrozen("move-tab", ac)137 d.afterRoleEffectGateFrozen("move-tab", ac)
125 }138 }
126- }, participants.clients...)139+ }}, participants.clients...)
127 if !frozen.acquired || !frozen.drained {140 if !frozen.acquired || !frozen.drained {
128 frozen.unfreeze()141 frozen.unfreeze()
129 return errMovePaneInvalid142 return errMovePaneInvalid
@@ -132,18 +145,8 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) {
132 commit.sourceRolesFrozen = true145 commit.sourceRolesFrozen = true
133 commit.handoffReq.roleEffectsFrozen = true146 commit.handoffReq.roleEffectsFrozen = true
134 } else if admission.finalSource && len(admission.sourceSnatched) > 0 {147 } else if admission.finalSource && len(admission.sourceSnatched) > 0 {
135- d.mu.Lock()148+ clients, interrupts := d.snapshotSnatchedRoleParticipants(source, admission.sourceSnatched)
136- source.mu.Lock()149+ frozen = freezeRoleEffectGatesWith(roleEffectFreezeOptions{interrupts: interrupts, nonblocking: true}, clients...)
137- clients := append([]*attachedClient(nil), admission.sourceSnatched...)
138- interrupts := make([]roleTransportInterrupt, 0, len(admission.sourceSnatched))
139- for _, ac := range admission.sourceSnatched {
140- if tr := ac.transportSnapshot(); tr.transport != nil {
141- interrupts = append(interrupts, roleTransportInterrupt{ac: ac, transport: tr})
142- }
143- }
144- source.mu.Unlock()
145- d.mu.Unlock()
146- frozen = tryFreezeRoleEffectGatesInterruptingObserved(interrupts, nil, clients...)
147 if !frozen.acquired || !frozen.drained {150 if !frozen.acquired || !frozen.drained {
148 frozen.unfreeze()151 frozen.unfreeze()
149 return errMovePaneInvalid152 return errMovePaneInvalid
@@ -167,72 +170,35 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) {
167 return errMoveStaleTarget170 return errMoveStaleTarget
168 }171 }
169 fences.Release()172 fences.Release()
170- if commit.oldTabCancel != nil {173+ postcommit := movePanePostcommitPlan{
171- commit.oldTabCancel()174+ source: source,
172- }175+ destination: destination,
173- for _, p := range admission.panes {176+ sourceTab: admission.tab,
174- if commit.sourceCleanupToken.current() {177+ destinationTab: admission.tab,
175- commit.sourceCleanupToken.ac.overlays.clearCopyModeForPane(p)178+ movedPane: firstMovePane(admission.panes),
176- commit.sourceCleanupToken.ac.pruneCaptureFrames(p)179+ movedPanes: admission.panes,
177- }180+ sourceCleanupToken: commit.sourceCleanupToken,
178- }181+ handoffResult: commit.handoffResult,
179- markSnapshotDirty(destination)182+ syncCleanup: commit.syncCleanup,
180- if !commit.sourceEmpty {183+ frozenRoles: frozen,
181- markSnapshotDirty(source)184+ rolesFrozen: rolesFrozen,
182- }185+ unlockDispatch: unlockDispatch,
183- commit.syncCleanup.finish()186+ reservation: reservation,
184- if rolesFrozen {187+ oldTabCancel: commit.oldTabCancel,
185- frozen.unfreeze()188+ sourceTabWasActive: !commit.sourceEmpty,
186- rolesFrozen = false189+ sourceTabRemoved: true,
187- }190+ sourceEmpty: commit.sourceEmpty,
188- unlockDispatch()191+ retiredParked: commit.retiredParked,
192+ retiredAttachments: commit.retiredAttachments,
193+ sourceMetadata: commit.sourceMetadata,
194+ sourceMetadataValid: commit.sourceMetadataValid,
195+ destinationMetadata: commit.destMetadata,
196+ destinationMetadataValid: commit.destMetadataValid,
197+ }
198+ rolesFrozen = false
189 dispatchHeld = false199 dispatchHeld = false
190- reservation.Release()
191 reservationHeld = false200 reservationHeld = false
192-201+ postcommit.execute(d)
193- if !commit.sourceEmpty {
194- if sourceTab := source.activeTab(); sourceTab != nil {
195- d.activateTab(source, sourceTab)
196- d.applyTabLayout(source, sourceTab)
197- }
198- }
199- d.applyTabLayout(destination, admission.tab)
200- if commit.sourceMetadataValid && !commit.sourceEmpty {
201- d.markCatalogueDirty(commit.sourceMetadata)
202- }
203- if commit.destMetadataValid {
204- d.markCatalogueDirty(commit.destMetadata)
205- }
206- if commit.handoffResult.published.ac != nil {
207- follower := commit.handoffResult.published.ac
208- d.applyHostTheme(destination, follower, follower.getClientTheme(), false)
209- follower.recordPreviousSession(source)
210- d.deferAttachmentTransitionCleanups(commit.handoffResult)
211- d.firstPaintForTransition(commit.handoffResult.published)
212- }
213- if commit.sourceEmpty {
214- retireEmptySessionAfterMove(d, source)
215- }
216- for _, attachment := range commit.retiredAttachments {
217- d.unregisterPreview(attachment.ac)
218- attachment.ac.clearPreviousSession()
219- attachment.ac.clearCaptureFrames()
220- d.notifyDetachedSnapshotAsync(attachment, ports.ReasonSessionKilled)
221- }
222- for _, retirement := range commit.retiredParked {
223- if retirement.parked != nil && retirement.parked.ac != nil {
224- d.unregisterPreview(retirement.parked.ac)
225- retirement.parked.ac.clearCaptureFrames()
226- }
227- }
228- finishParkedAttachmentRetirements(commit.retiredParked)
229- if commit.sourceEmpty && commit.sourceMetadataValid && d.persistEnabled {
230- if err := d.beginSnapshotPurge(source.name, source.incarnation); err == nil {
231- if err := d.finishSnapshotPurge(d.serveCtx, source.name, source.incarnation); err != nil {
232- d.log.Warn("moving final tab source purge failed", "err", err, "session", source.name)
233- }
234- }
235- }
236 return nil202 return nil
237 }203 }
238 204
@@ -260,6 +226,9 @@ func (d *Daemon) snapshotMoveTabAdmission(req moveTabRequest, source, destinatio
260 if !moved.floatingTransferableLocked() {226 if !moved.floatingTransferableLocked() {
261 return nil, errMoveFloatingWarming227 return nil, errMoveFloatingWarming
262 }228 }
229+ if moved.tree == nil || moved.tree.Root == nil {
230+ return nil, errMovePaneInvalid
231+ }
263 if _, ok := layout.Solve(moved.tree.Root, domain.Rect{Width: destinationSize.Cols, Height: destinationSize.Rows}); !ok {232 if _, ok := layout.Solve(moved.tree.Root, domain.Rect{Width: destinationSize.Cols, Height: destinationSize.Rows}); !ok {
264 return nil, errMoveTooSmall233 return nil, errMoveTooSmall
265 }234 }
M internal/usecase/daemon/move_tab_handoff_test.go +10 -12
@@ -10,7 +10,7 @@ import (
1010 scopy "github.com/bnema/vev/internal/usecase/copy"
1111 )
1212
13-func setupMoveTabFinalSourceHandoff(t *testing.T) (*Daemon, *session, *attachedClient, *session, *attachedClient, *tab) {
13+func setupMoveTabFinalSourceHandoff(t *testing.T) (*Daemon, *session, *attachedClient, *session, *attachedClient, *closeTrackingTransport, *tab) {
1414 t.Helper()
1515 d, source, follower, _ := newManualSessionWithPTYs(t, newQuietPTY())
1616 follower.replaceTransport(&closeTrackingTransport{})
@@ -30,7 +30,7 @@ func setupMoveTabFinalSourceHandoff(t *testing.T) (*Daemon, *session, *attachedC
3030 destination.mu.Unlock()
3131 require.NotNil(t, d.attachCoordinator(destination, nil, displaced, true))
3232
33- return d, source, follower, destination, displaced, moved
33+ return d, source, follower, destination, displaced, displacedTransport, moved
3434 }
3535
3636 func moveTabFinalSource(t *testing.T, d *Daemon, source, destination *session, moved *tab) {
@@ -56,8 +56,7 @@ func detachedWithReason(frames []ports.Frame, reason uint8) bool {
5656 }
5757
5858 func TestMoveTabFinalSourceSnatchesDestinationActiveClient(t *testing.T) {
59- d, source, follower, destination, displaced, moved := setupMoveTabFinalSourceHandoff(t)
60- displacedTransport := displaced.tr.(*closeTrackingTransport)
59+ d, source, follower, destination, displaced, displacedTransport, moved := setupMoveTabFinalSourceHandoff(t)
6160
6261 moveTabFinalSource(t, d, source, destination, moved)
6362 d.attachmentCleanupWg.Wait()
@@ -82,7 +81,7 @@ func TestMoveTabFinalSourceSnatchesDestinationActiveClient(t *testing.T) {
8281 }
8382
8483 func TestMoveTabFinalSourcePreservesDestinationSnatchedWaiters(t *testing.T) {
85- d, source, follower, destination, displaced, moved := setupMoveTabFinalSourceHandoff(t)
84+ d, source, follower, destination, displaced, _, moved := setupMoveTabFinalSourceHandoff(t)
8685
8786 waitingTransport := &closeTrackingTransport{}
8887 waiting := &attachedClient{tr: waitingTransport, output: newOutputStateStream(), size: follower.size}
@@ -104,7 +103,7 @@ func TestMoveTabFinalSourcePreservesDestinationSnatchedWaiters(t *testing.T) {
104103 }
105104
106105 func TestMoveTabFinalSourceRetiresSourceParkedAndSnatchedClients(t *testing.T) {
107- d, source, follower, destination, _, moved := setupMoveTabFinalSourceHandoff(t)
106+ d, source, follower, destination, _, _, moved := setupMoveTabFinalSourceHandoff(t)
108107
109108 parkedTransport := &closeTrackingTransport{}
110109 parked := &attachedClient{tr: parkedTransport, output: newOutputStateStream(), size: follower.size, resumeCapable: true}
@@ -132,7 +131,7 @@ func TestMoveTabFinalSourceRetiresSourceParkedAndSnatchedClients(t *testing.T) {
132131 }
133132
134133 func TestMoveTabFinalSourceClearsFollowerCopyCapturePreservesDestinationWaiterOverlays(t *testing.T) {
135- d, source, follower, destination, _, moved := setupMoveTabFinalSourceHandoff(t)
134+ d, source, follower, destination, _, _, moved := setupMoveTabFinalSourceHandoff(t)
136135 movedPane := moved.focusedPane()
137136
138137 follower.overlays.copyMu.Lock()
@@ -182,7 +181,7 @@ func TestMoveTabFinalSourceClearsFollowerCopyCapturePreservesDestinationWaiterOv
182181 }
183182
184183 func TestMoveTabFinalSourceFirstPaintNotBlockedByDisplacedPanelCleanup(t *testing.T) {
185- d, source, follower, destination, displaced, moved := setupMoveTabFinalSourceHandoff(t)
184+ d, source, follower, destination, displaced, _, moved := setupMoveTabFinalSourceHandoff(t)
186185 followerRebased := make(chan struct{})
187186 follower.renderStages.handoffRebase = func() { close(followerRebased) }
188187
@@ -206,7 +205,7 @@ func TestMoveTabFinalSourceFirstPaintNotBlockedByDisplacedPanelCleanup(t *testin
206205 }
207206
208207 func TestMoveTabFinalSourceConcurrentReclaimKeepsNewestOwnerAndLease(t *testing.T) {
209- d, source, follower, destination, displaced, moved := setupMoveTabFinalSourceHandoff(t)
208+ d, source, follower, destination, displaced, _, moved := setupMoveTabFinalSourceHandoff(t)
210209
211210 paintAdmitted := make(chan struct{})
212211 releasePaint := make(chan struct{})
@@ -252,7 +251,7 @@ func TestMoveTabFinalSourceConcurrentReclaimKeepsNewestOwnerAndLease(t *testing.
252251 }
253252
254253 func TestMoveTabFinalSourcePostCommitPaintFailureDoesNotRollback(t *testing.T) {
255- d, source, follower, destination, _, moved := setupMoveTabFinalSourceHandoff(t)
254+ d, source, follower, destination, _, _, moved := setupMoveTabFinalSourceHandoff(t)
256255
257256 d.afterRoleEffectAdmitted = func(token attachmentRoleToken) {
258257 if token.ac == follower {
@@ -272,8 +271,7 @@ func TestMoveTabFinalSourcePostCommitPaintFailureDoesNotRollback(t *testing.T) {
272271 }
273272
274273 func TestMoveTabFinalSourceHandoffCommitHidesPartialPublication(t *testing.T) {
275- d, source, follower, destination, displaced, moved := setupMoveTabFinalSourceHandoff(t)
276- _ = displaced
274+ d, source, follower, destination, displaced, _, moved := setupMoveTabFinalSourceHandoff(t)
277275
278276 commitEntered := make(chan struct{})
279277 releaseCommit := make(chan struct{})
@@ -10,7 +10,7 @@ import (
10 scopy "github.com/bnema/vev/internal/usecase/copy"10 scopy "github.com/bnema/vev/internal/usecase/copy"
11 )11 )
12 12
13-func setupMoveTabFinalSourceHandoff(t *testing.T) (*Daemon, *session, *attachedClient, *session, *attachedClient, *tab) {13+func setupMoveTabFinalSourceHandoff(t *testing.T) (*Daemon, *session, *attachedClient, *session, *attachedClient, *closeTrackingTransport, *tab) {
14 t.Helper()14 t.Helper()
15 d, source, follower, _ := newManualSessionWithPTYs(t, newQuietPTY())15 d, source, follower, _ := newManualSessionWithPTYs(t, newQuietPTY())
16 follower.replaceTransport(&closeTrackingTransport{})16 follower.replaceTransport(&closeTrackingTransport{})
@@ -30,7 +30,7 @@ func setupMoveTabFinalSourceHandoff(t *testing.T) (*Daemon, *session, *attachedC
30 destination.mu.Unlock()30 destination.mu.Unlock()
31 require.NotNil(t, d.attachCoordinator(destination, nil, displaced, true))31 require.NotNil(t, d.attachCoordinator(destination, nil, displaced, true))
32 32
33- return d, source, follower, destination, displaced, moved33+ return d, source, follower, destination, displaced, displacedTransport, moved
34 }34 }
35 35
36 func moveTabFinalSource(t *testing.T, d *Daemon, source, destination *session, moved *tab) {36 func moveTabFinalSource(t *testing.T, d *Daemon, source, destination *session, moved *tab) {
@@ -56,8 +56,7 @@ func detachedWithReason(frames []ports.Frame, reason uint8) bool {
56 }56 }
57 57
58 func TestMoveTabFinalSourceSnatchesDestinationActiveClient(t *testing.T) {58 func TestMoveTabFinalSourceSnatchesDestinationActiveClient(t *testing.T) {
59- d, source, follower, destination, displaced, moved := setupMoveTabFinalSourceHandoff(t)59+ d, source, follower, destination, displaced, displacedTransport, moved := setupMoveTabFinalSourceHandoff(t)
60- displacedTransport := displaced.tr.(*closeTrackingTransport)
61 60
62 moveTabFinalSource(t, d, source, destination, moved)61 moveTabFinalSource(t, d, source, destination, moved)
63 d.attachmentCleanupWg.Wait()62 d.attachmentCleanupWg.Wait()
@@ -82,7 +81,7 @@ func TestMoveTabFinalSourceSnatchesDestinationActiveClient(t *testing.T) {
82 }81 }
83 82
84 func TestMoveTabFinalSourcePreservesDestinationSnatchedWaiters(t *testing.T) {83 func TestMoveTabFinalSourcePreservesDestinationSnatchedWaiters(t *testing.T) {
85- d, source, follower, destination, displaced, moved := setupMoveTabFinalSourceHandoff(t)84+ d, source, follower, destination, displaced, _, moved := setupMoveTabFinalSourceHandoff(t)
86 85
87 waitingTransport := &closeTrackingTransport{}86 waitingTransport := &closeTrackingTransport{}
88 waiting := &attachedClient{tr: waitingTransport, output: newOutputStateStream(), size: follower.size}87 waiting := &attachedClient{tr: waitingTransport, output: newOutputStateStream(), size: follower.size}
@@ -104,7 +103,7 @@ func TestMoveTabFinalSourcePreservesDestinationSnatchedWaiters(t *testing.T) {
104 }103 }
105 104
106 func TestMoveTabFinalSourceRetiresSourceParkedAndSnatchedClients(t *testing.T) {105 func TestMoveTabFinalSourceRetiresSourceParkedAndSnatchedClients(t *testing.T) {
107- d, source, follower, destination, _, moved := setupMoveTabFinalSourceHandoff(t)106+ d, source, follower, destination, _, _, moved := setupMoveTabFinalSourceHandoff(t)
108 107
109 parkedTransport := &closeTrackingTransport{}108 parkedTransport := &closeTrackingTransport{}
110 parked := &attachedClient{tr: parkedTransport, output: newOutputStateStream(), size: follower.size, resumeCapable: true}109 parked := &attachedClient{tr: parkedTransport, output: newOutputStateStream(), size: follower.size, resumeCapable: true}
@@ -132,7 +131,7 @@ func TestMoveTabFinalSourceRetiresSourceParkedAndSnatchedClients(t *testing.T) {
132 }131 }
133 132
134 func TestMoveTabFinalSourceClearsFollowerCopyCapturePreservesDestinationWaiterOverlays(t *testing.T) {133 func TestMoveTabFinalSourceClearsFollowerCopyCapturePreservesDestinationWaiterOverlays(t *testing.T) {
135- d, source, follower, destination, _, moved := setupMoveTabFinalSourceHandoff(t)134+ d, source, follower, destination, _, _, moved := setupMoveTabFinalSourceHandoff(t)
136 movedPane := moved.focusedPane()135 movedPane := moved.focusedPane()
137 136
138 follower.overlays.copyMu.Lock()137 follower.overlays.copyMu.Lock()
@@ -182,7 +181,7 @@ func TestMoveTabFinalSourceClearsFollowerCopyCapturePreservesDestinationWaiterOv
182 }181 }
183 182
184 func TestMoveTabFinalSourceFirstPaintNotBlockedByDisplacedPanelCleanup(t *testing.T) {183 func TestMoveTabFinalSourceFirstPaintNotBlockedByDisplacedPanelCleanup(t *testing.T) {
185- d, source, follower, destination, displaced, moved := setupMoveTabFinalSourceHandoff(t)184+ d, source, follower, destination, displaced, _, moved := setupMoveTabFinalSourceHandoff(t)
186 followerRebased := make(chan struct{})185 followerRebased := make(chan struct{})
187 follower.renderStages.handoffRebase = func() { close(followerRebased) }186 follower.renderStages.handoffRebase = func() { close(followerRebased) }
188 187
@@ -206,7 +205,7 @@ func TestMoveTabFinalSourceFirstPaintNotBlockedByDisplacedPanelCleanup(t *testin
206 }205 }
207 206
208 func TestMoveTabFinalSourceConcurrentReclaimKeepsNewestOwnerAndLease(t *testing.T) {207 func TestMoveTabFinalSourceConcurrentReclaimKeepsNewestOwnerAndLease(t *testing.T) {
209- d, source, follower, destination, displaced, moved := setupMoveTabFinalSourceHandoff(t)208+ d, source, follower, destination, displaced, _, moved := setupMoveTabFinalSourceHandoff(t)
210 209
211 paintAdmitted := make(chan struct{})210 paintAdmitted := make(chan struct{})
212 releasePaint := make(chan struct{})211 releasePaint := make(chan struct{})
@@ -252,7 +251,7 @@ func TestMoveTabFinalSourceConcurrentReclaimKeepsNewestOwnerAndLease(t *testing.
252 }251 }
253 252
254 func TestMoveTabFinalSourcePostCommitPaintFailureDoesNotRollback(t *testing.T) {253 func TestMoveTabFinalSourcePostCommitPaintFailureDoesNotRollback(t *testing.T) {
255- d, source, follower, destination, _, moved := setupMoveTabFinalSourceHandoff(t)254+ d, source, follower, destination, _, _, moved := setupMoveTabFinalSourceHandoff(t)
256 255
257 d.afterRoleEffectAdmitted = func(token attachmentRoleToken) {256 d.afterRoleEffectAdmitted = func(token attachmentRoleToken) {
258 if token.ac == follower {257 if token.ac == follower {
@@ -272,8 +271,7 @@ func TestMoveTabFinalSourcePostCommitPaintFailureDoesNotRollback(t *testing.T) {
272 }271 }
273 272
274 func TestMoveTabFinalSourceHandoffCommitHidesPartialPublication(t *testing.T) {273 func TestMoveTabFinalSourceHandoffCommitHidesPartialPublication(t *testing.T) {
275- d, source, follower, destination, displaced, moved := setupMoveTabFinalSourceHandoff(t)274+ d, source, follower, destination, displaced, _, moved := setupMoveTabFinalSourceHandoff(t)
276- _ = displaced
277 275
278 commitEntered := make(chan struct{})276 commitEntered := make(chan struct{})
279 releaseCommit := make(chan struct{})277 releaseCommit := make(chan struct{})
M internal/usecase/daemon/move_tab_test.go +26 -3
@@ -10,7 +10,6 @@ import (
1010 "github.com/stretchr/testify/require"
1111
1212 "github.com/bnema/vev/internal/domain"
13- "github.com/bnema/vev/internal/ports"
1413 recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
1514 "github.com/bnema/vev/pkg/renderer"
1615 )
@@ -79,7 +78,7 @@ func TestMoveTabPreservesWholeTabAndActivatesLogicalNeighbor(t *testing.T) {
7978 require.Same(t, tiledScreen, tiled.screen)
8079 require.Same(t, floating, moved.floating.pane)
8180 require.Same(t, floatingPTY, floating.pty)
82- require.Equal(t, floatingState(floatingHidden), moved.floating.state)
81+ require.Equal(t, floatingHidden, moved.floating.state)
8382 require.Equal(t, uint64(7), moved.floating.generation)
8483 require.Equal(t, tiledOwnerGeneration+1, tiled.ownerGeneration)
8584 require.Equal(t, floatingOwnerGeneration+1, floating.ownerGeneration)
@@ -413,12 +412,21 @@ func TestMoveTabOppositeDirectionsDoNotDeadlock(t *testing.T) {
413412 }
414413
415414 type moveTabPurgeCatalogue struct {
416- ports.Catalogue
417415 mu sync.Mutex
418416 records map[string]domain.CatalogueRecord
419417 events []string
420418 }
421419
420+func (c *moveTabPurgeCatalogue) Records() ([]domain.CatalogueRecord, error) {
421+ c.mu.Lock()
422+ defer c.mu.Unlock()
423+ records := make([]domain.CatalogueRecord, 0, len(c.records))
424+ for _, record := range c.records {
425+ records = append(records, record)
426+ }
427+ return records, nil
428+}
429+
422430 func (c *moveTabPurgeCatalogue) Record(name string) (domain.CatalogueRecord, bool, error) {
423431 c.mu.Lock()
424432 defer c.mu.Unlock()
@@ -426,6 +434,18 @@ func (c *moveTabPurgeCatalogue) Record(name string) (domain.CatalogueRecord, boo
426434 return record, ok, nil
427435 }
428436
437+func (c *moveTabPurgeCatalogue) Create(domain.CatalogueRecord) error {
438+ return errors.New("move tab purge catalogue: Create is unsupported")
439+}
440+
441+func (c *moveTabPurgeCatalogue) Replace(string, domain.CatalogueRecord) error {
442+ return errors.New("move tab purge catalogue: Replace is unsupported")
443+}
444+
445+func (c *moveTabPurgeCatalogue) Rename(string, domain.CatalogueRecord) error {
446+ return errors.New("move tab purge catalogue: Rename is unsupported")
447+}
448+
429449 func (c *moveTabPurgeCatalogue) Delete(name string) error {
430450 c.mu.Lock()
431451 defer c.mu.Unlock()
@@ -441,6 +461,9 @@ func (c *moveTabPurgeCatalogue) UpdateMetadata(update domain.CatalogueMetadataUp
441461 return nil
442462 }
443463
464+func (c *moveTabPurgeCatalogue) Sync() error { return nil }
465+func (c *moveTabPurgeCatalogue) Close() error { return nil }
466+
444467 type moveTabPurgeRepository struct {
445468 noOpSnapshotRepository
446469 d *Daemon
@@ -10,7 +10,6 @@ import (
10 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
11 11
12 "github.com/bnema/vev/internal/domain"12 "github.com/bnema/vev/internal/domain"
13- "github.com/bnema/vev/internal/ports"
14 recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"13 recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
15 "github.com/bnema/vev/pkg/renderer"14 "github.com/bnema/vev/pkg/renderer"
16 )15 )
@@ -79,7 +78,7 @@ func TestMoveTabPreservesWholeTabAndActivatesLogicalNeighbor(t *testing.T) {
79 require.Same(t, tiledScreen, tiled.screen)78 require.Same(t, tiledScreen, tiled.screen)
80 require.Same(t, floating, moved.floating.pane)79 require.Same(t, floating, moved.floating.pane)
81 require.Same(t, floatingPTY, floating.pty)80 require.Same(t, floatingPTY, floating.pty)
82- require.Equal(t, floatingState(floatingHidden), moved.floating.state)81+ require.Equal(t, floatingHidden, moved.floating.state)
83 require.Equal(t, uint64(7), moved.floating.generation)82 require.Equal(t, uint64(7), moved.floating.generation)
84 require.Equal(t, tiledOwnerGeneration+1, tiled.ownerGeneration)83 require.Equal(t, tiledOwnerGeneration+1, tiled.ownerGeneration)
85 require.Equal(t, floatingOwnerGeneration+1, floating.ownerGeneration)84 require.Equal(t, floatingOwnerGeneration+1, floating.ownerGeneration)
@@ -413,12 +412,21 @@ func TestMoveTabOppositeDirectionsDoNotDeadlock(t *testing.T) {
413 }412 }
414 413
415 type moveTabPurgeCatalogue struct {414 type moveTabPurgeCatalogue struct {
416- ports.Catalogue
417 mu sync.Mutex415 mu sync.Mutex
418 records map[string]domain.CatalogueRecord416 records map[string]domain.CatalogueRecord
419 events []string417 events []string
420 }418 }
421 419
420+func (c *moveTabPurgeCatalogue) Records() ([]domain.CatalogueRecord, error) {
421+ c.mu.Lock()
422+ defer c.mu.Unlock()
423+ records := make([]domain.CatalogueRecord, 0, len(c.records))
424+ for _, record := range c.records {
425+ records = append(records, record)
426+ }
427+ return records, nil
428+}
429+
422 func (c *moveTabPurgeCatalogue) Record(name string) (domain.CatalogueRecord, bool, error) {430 func (c *moveTabPurgeCatalogue) Record(name string) (domain.CatalogueRecord, bool, error) {
423 c.mu.Lock()431 c.mu.Lock()
424 defer c.mu.Unlock()432 defer c.mu.Unlock()
@@ -426,6 +434,18 @@ func (c *moveTabPurgeCatalogue) Record(name string) (domain.CatalogueRecord, boo
426 return record, ok, nil434 return record, ok, nil
427 }435 }
428 436
437+func (c *moveTabPurgeCatalogue) Create(domain.CatalogueRecord) error {
438+ return errors.New("move tab purge catalogue: Create is unsupported")
439+}
440+
441+func (c *moveTabPurgeCatalogue) Replace(string, domain.CatalogueRecord) error {
442+ return errors.New("move tab purge catalogue: Replace is unsupported")
443+}
444+
445+func (c *moveTabPurgeCatalogue) Rename(string, domain.CatalogueRecord) error {
446+ return errors.New("move tab purge catalogue: Rename is unsupported")
447+}
448+
429 func (c *moveTabPurgeCatalogue) Delete(name string) error {449 func (c *moveTabPurgeCatalogue) Delete(name string) error {
430 c.mu.Lock()450 c.mu.Lock()
431 defer c.mu.Unlock()451 defer c.mu.Unlock()
@@ -441,6 +461,9 @@ func (c *moveTabPurgeCatalogue) UpdateMetadata(update domain.CatalogueMetadataUp
441 return nil461 return nil
442 }462 }
443 463
464+func (c *moveTabPurgeCatalogue) Sync() error { return nil }
465+func (c *moveTabPurgeCatalogue) Close() error { return nil }
466+
444 type moveTabPurgeRepository struct {467 type moveTabPurgeRepository struct {
445 noOpSnapshotRepository468 noOpSnapshotRepository
446 d *Daemon469 d *Daemon
M internal/usecase/daemon/move_test.go +5 -4
@@ -3,6 +3,7 @@ package daemon
33 import (
44 "context"
55 "os"
6+ "sync/atomic"
67 "testing"
78
89 "github.com/stretchr/testify/require"
@@ -94,8 +95,8 @@ func TestMovePaneFinalSourceClientFollowsDestination(t *testing.T) {
9495
9596 rc := d.attachCoordinator(source, nil, client, true)
9697 require.NotNil(t, rc)
97- rebased := false
98- client.renderStages.handoffRebase = func() { rebased = true }
98+ var rebased atomic.Bool
99+ client.renderStages.handoffRebase = func() { rebased.Store(true) }
99100 destination := &session{id: "destination", name: "destination", tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}, active: 0}
100101 destinationTab := destination.tabs[0]
101102 destinationTab.stableID = "destination-tab"
@@ -117,8 +118,8 @@ func TestMovePaneFinalSourceClientFollowsDestination(t *testing.T) {
117118 require.Same(t, client, destination.client)
118119 require.Equal(t, attachmentActive, destination.attachmentRole(client))
119120 require.Same(t, destinationTab, destination.tabs[destination.active])
120- require.True(t, rebased, "final-source follower must rebase output before its first paint")
121- require.True(t, moved.ctx.Err() == nil)
121+ require.True(t, rebased.Load(), "final-source follower must rebase output before its first paint")
122+ require.NoError(t, moved.ctx.Err(), "transferred pane process must remain alive")
122123 }
123124
124125 func TestMovePaneRetiresEmptySourceWithoutClosingTransferredResources(t *testing.T) {
@@ -3,6 +3,7 @@ package daemon
3 import (3 import (
4 "context"4 "context"
5 "os"5 "os"
6+ "sync/atomic"
6 "testing"7 "testing"
7 8
8 "github.com/stretchr/testify/require"9 "github.com/stretchr/testify/require"
@@ -94,8 +95,8 @@ func TestMovePaneFinalSourceClientFollowsDestination(t *testing.T) {
94 95
95 rc := d.attachCoordinator(source, nil, client, true)96 rc := d.attachCoordinator(source, nil, client, true)
96 require.NotNil(t, rc)97 require.NotNil(t, rc)
97- rebased := false98+ var rebased atomic.Bool
98- client.renderStages.handoffRebase = func() { rebased = true }99+ client.renderStages.handoffRebase = func() { rebased.Store(true) }
99 destination := &session{id: "destination", name: "destination", tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}, active: 0}100 destination := &session{id: "destination", name: "destination", tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}, active: 0}
100 destinationTab := destination.tabs[0]101 destinationTab := destination.tabs[0]
101 destinationTab.stableID = "destination-tab"102 destinationTab.stableID = "destination-tab"
@@ -117,8 +118,8 @@ func TestMovePaneFinalSourceClientFollowsDestination(t *testing.T) {
117 require.Same(t, client, destination.client)118 require.Same(t, client, destination.client)
118 require.Equal(t, attachmentActive, destination.attachmentRole(client))119 require.Equal(t, attachmentActive, destination.attachmentRole(client))
119 require.Same(t, destinationTab, destination.tabs[destination.active])120 require.Same(t, destinationTab, destination.tabs[destination.active])
120- require.True(t, rebased, "final-source follower must rebase output before its first paint")121+ require.True(t, rebased.Load(), "final-source follower must rebase output before its first paint")
121- require.True(t, moved.ctx.Err() == nil)122+ require.NoError(t, moved.ctx.Err(), "transferred pane process must remain alive")
122 }123 }
123 124
124 func TestMovePaneRetiresEmptySourceWithoutClosingTransferredResources(t *testing.T) {125 func TestMovePaneRetiresEmptySourceWithoutClosingTransferredResources(t *testing.T) {
M internal/usecase/daemon/navigation_role_handoff_test.go +10 -3
@@ -1,6 +1,7 @@
11 package daemon
22
33 import (
4+ "sync"
45 "testing"
56 "time"
67
@@ -105,9 +106,10 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
105106
106107 admissionEnded := make(chan struct{})
107108 releaseAction := make(chan struct{})
109+ var admissionEndedOnce sync.Once
108110 d.afterActionRoleEffectEnded = func(action string) {
109111 if action == tt.action {
110- close(admissionEnded)
112+ admissionEndedOnce.Do(func() { close(admissionEnded) })
111113 <-releaseAction
112114 }
113115 }
@@ -159,9 +161,10 @@ func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing.
159161
160162 ended := make(chan struct{})
161163 release := make(chan struct{})
164+ var endedOnce sync.Once
162165 d.afterActionRoleEffectEnded = func(action string) {
163166 if action == "picker-stopped" {
164- close(ended)
167+ endedOnce.Do(func() { close(ended) })
165168 <-release
166169 }
167170 }
@@ -170,7 +173,11 @@ func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing.
170173 go func() {
171174 done <- d.switchToTargetForRole(effect.roleToken(), picker.Target{Name: "stopped", Stopped: true, ExpectedCreatedAt: &expectedCreatedAt}, sessionHandoffGuard{}, "picker-stopped")
172175 }()
173- <-ended
176+ select {
177+ case <-ended:
178+ case <-time.After(time.Second):
179+ t.Fatal("stopped-session handoff did not release its role ticket")
180+ }
174181
175182 next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
176183 next.initOverlays()
@@ -1,6 +1,7 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "sync"
4 "testing"5 "testing"
5 "time"6 "time"
6 7
@@ -105,9 +106,10 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
105 106
106 admissionEnded := make(chan struct{})107 admissionEnded := make(chan struct{})
107 releaseAction := make(chan struct{})108 releaseAction := make(chan struct{})
109+ var admissionEndedOnce sync.Once
108 d.afterActionRoleEffectEnded = func(action string) {110 d.afterActionRoleEffectEnded = func(action string) {
109 if action == tt.action {111 if action == tt.action {
110- close(admissionEnded)112+ admissionEndedOnce.Do(func() { close(admissionEnded) })
111 <-releaseAction113 <-releaseAction
112 }114 }
113 }115 }
@@ -159,9 +161,10 @@ func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing.
159 161
160 ended := make(chan struct{})162 ended := make(chan struct{})
161 release := make(chan struct{})163 release := make(chan struct{})
164+ var endedOnce sync.Once
162 d.afterActionRoleEffectEnded = func(action string) {165 d.afterActionRoleEffectEnded = func(action string) {
163 if action == "picker-stopped" {166 if action == "picker-stopped" {
164- close(ended)167+ endedOnce.Do(func() { close(ended) })
165 <-release168 <-release
166 }169 }
167 }170 }
@@ -170,7 +173,11 @@ func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing.
170 go func() {173 go func() {
171 done <- d.switchToTargetForRole(effect.roleToken(), picker.Target{Name: "stopped", Stopped: true, ExpectedCreatedAt: &expectedCreatedAt}, sessionHandoffGuard{}, "picker-stopped")174 done <- d.switchToTargetForRole(effect.roleToken(), picker.Target{Name: "stopped", Stopped: true, ExpectedCreatedAt: &expectedCreatedAt}, sessionHandoffGuard{}, "picker-stopped")
172 }()175 }()
173- <-ended176+ select {
177+ case <-ended:
178+ case <-time.After(time.Second):
179+ t.Fatal("stopped-session handoff did not release its role ticket")
180+ }
174 181
175 next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}182 next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
176 next.initOverlays()183 next.initOverlays()
M internal/usecase/daemon/navigation_role_incarnation_test.go +2 -2
@@ -66,10 +66,10 @@ func TestNavigationHandoffRejectsResumedOrReboundInitiatorIncarnation(t *testing
6666 go func() {
6767 done <- d.switchToTargetForRole(effect.roleToken(), picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "test-handoff")
6868 }()
69- <-ended
69+ awaitTestCompletion(t, ended, "test handoff did not release its role ticket")
7070 tt.rebind(t, d, source, ac)
7171 close(release)
72- require.ErrorIs(t, <-done, errAttachmentTransition)
72+ require.ErrorIs(t, awaitTestValue(t, done, "test handoff did not finish"), errAttachmentTransition)
7373
7474 require.Same(t, source, ac.currentSession())
7575 target.mu.Lock()
@@ -66,10 +66,10 @@ func TestNavigationHandoffRejectsResumedOrReboundInitiatorIncarnation(t *testing
66 go func() {66 go func() {
67 done <- d.switchToTargetForRole(effect.roleToken(), picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "test-handoff")67 done <- d.switchToTargetForRole(effect.roleToken(), picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "test-handoff")
68 }()68 }()
69- <-ended69+ awaitTestCompletion(t, ended, "test handoff did not release its role ticket")
70 tt.rebind(t, d, source, ac)70 tt.rebind(t, d, source, ac)
71 close(release)71 close(release)
72- require.ErrorIs(t, <-done, errAttachmentTransition)72+ require.ErrorIs(t, awaitTestValue(t, done, "test handoff did not finish"), errAttachmentTransition)
73 73
74 require.Same(t, source, ac.currentSession())74 require.Same(t, source, ac.currentSession())
75 target.mu.Lock()75 target.mu.Lock()
M internal/usecase/daemon/pane_actions.go +3 -3
@@ -359,7 +359,7 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
359359 return nil
360360 }
361361
362-func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Direction, effects ...*roleEffectTicket) error {
362+func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Direction, effect *roleEffectTicket) error {
363363 target := resolveDaemonActionTarget(sess)
364364 oldFocus := layout.PaneID("")
365365 if target.pane != nil {
@@ -384,8 +384,8 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio
384384 if ac == nil {
385385 return errNoNeighbor
386386 }
387- if len(effects) != 0 && effects[0] != nil {
388- return d.switchToTargetForRole(effects[0].roleToken(), sessionTarget, sessionHandoffGuard{expectedSource: target.tab}, "overflow-session")
387+ if effect != nil {
388+ return d.switchToTargetForRole(effect.roleToken(), sessionTarget, sessionHandoffGuard{expectedSource: target.tab}, "overflow-session")
389389 }
390390 return d.commitSessionOverflow(sess, ac, target.tab, sessionTarget)
391391 }
@@ -359,7 +359,7 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
359 return nil359 return nil
360 }360 }
361 361
362-func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Direction, effects ...*roleEffectTicket) error {362+func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Direction, effect *roleEffectTicket) error {
363 target := resolveDaemonActionTarget(sess)363 target := resolveDaemonActionTarget(sess)
364 oldFocus := layout.PaneID("")364 oldFocus := layout.PaneID("")
365 if target.pane != nil {365 if target.pane != nil {
@@ -384,8 +384,8 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio
384 if ac == nil {384 if ac == nil {
385 return errNoNeighbor385 return errNoNeighbor
386 }386 }
387- if len(effects) != 0 && effects[0] != nil {387+ if effect != nil {
388- return d.switchToTargetForRole(effects[0].roleToken(), sessionTarget, sessionHandoffGuard{expectedSource: target.tab}, "overflow-session")388+ return d.switchToTargetForRole(effect.roleToken(), sessionTarget, sessionHandoffGuard{expectedSource: target.tab}, "overflow-session")
389 }389 }
390 return d.commitSessionOverflow(sess, ac, target.tab, sessionTarget)390 return d.commitSessionOverflow(sess, ac, target.tab, sessionTarget)
391 }391 }
M internal/usecase/daemon/pane_actions_test.go +2 -2
@@ -625,7 +625,7 @@ func TestFocusDirMovesFocusAndExitsCopyMode(t *testing.T) {
625625 ac.initOverlays()
626626 ac.overlays.copyMode = &scopy.Mode{}
627627
628- require.NoError(t, d.focusDir(sess, ac, layout.Right))
628+ require.NoError(t, d.focusDir(sess, ac, layout.Right, nil))
629629
630630 require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)
631631 require.Nil(t, ac.overlays.copyMode)
@@ -726,7 +726,7 @@ func TestStackFocusWalkExpandsAndOverflowRefuses(t *testing.T) {
726726 require.NoError(t, d.stackPane(sess, nil))
727727 oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 2}).Return(nil).Once()
728728
729- require.NoError(t, d.focusDir(sess, nil, layout.Up))
729+ require.NoError(t, d.focusDir(sess, nil, layout.Up, nil))
730730
731731 tb := sess.activeTab()
732732 require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus)
@@ -625,7 +625,7 @@ func TestFocusDirMovesFocusAndExitsCopyMode(t *testing.T) {
625 ac.initOverlays()625 ac.initOverlays()
626 ac.overlays.copyMode = &scopy.Mode{}626 ac.overlays.copyMode = &scopy.Mode{}
627 627
628- require.NoError(t, d.focusDir(sess, ac, layout.Right))628+ require.NoError(t, d.focusDir(sess, ac, layout.Right, nil))
629 629
630 require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)630 require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)
631 require.Nil(t, ac.overlays.copyMode)631 require.Nil(t, ac.overlays.copyMode)
@@ -726,7 +726,7 @@ func TestStackFocusWalkExpandsAndOverflowRefuses(t *testing.T) {
726 require.NoError(t, d.stackPane(sess, nil))726 require.NoError(t, d.stackPane(sess, nil))
727 oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 2}).Return(nil).Once()727 oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 2}).Return(nil).Once()
728 728
729- require.NoError(t, d.focusDir(sess, nil, layout.Up))729+ require.NoError(t, d.focusDir(sess, nil, layout.Up, nil))
730 730
731 tb := sess.activeTab()731 tb := sess.activeTab()
732 require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus)732 require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus)
M internal/usecase/daemon/pane_lifetime_test.go +121 -167
@@ -174,12 +174,18 @@ func TestPaneOpenErrorsReleaseReturnedPTYWithoutPublication(t *testing.T) {
174174 }
175175 tb.mu.Lock()
176176 floating := tb.floating.pane
177+ var floatingPTY ports.PTY
178+ if floating != nil {
179+ floating.mu.Lock()
180+ floatingPTY = floating.pty
181+ floating.mu.Unlock()
182+ }
177183 publishedFailedPTY := false
178184 for _, published := range tb.panes {
179185 publishedFailedPTY = publishedFailedPTY || published.pty == pty
180186 }
181187 tb.mu.Unlock()
182- require.NotSame(t, pty, floating)
188+ require.NotEqual(t, pty, floatingPTY)
183189 require.False(t, publishedFailedPTY, "failed Open must not publish a pane or owner")
184190 }
185191 require.Zero(t, pty.readerStarts.Load(), "failed Open must not start a reader")
@@ -206,176 +212,124 @@ func (f *paneErrorFactory) Open(ctx context.Context, _ string, _ []string, _ []s
206212 return f.pty, f.err
207213 }
208214
209-func TestInitialPaneProcessLifetimeIsDaemonRooted(t *testing.T) {
210- factory := &lifetimePTYFactory{}
211- d := newTestDaemon(t, factory, stubClock{})
212- sess, err := createSessionForTest(d, "work", true, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, nil)
213- require.NoError(t, err)
214-
215- p := sess.activeTab().focusedPane()
216- pty, openCtx := factory.opened(0)
217- require.NotNil(t, p.ctx)
218- require.Equal(t, openCtx, p.ctx)
219- require.NotNil(t, p.cancel)
220-
221- sess.cancel()
222- select {
223- case <-openCtx.Done():
224- t.Fatal("session cancellation killed a published pane process")
225- default:
226- }
227-
228- require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
229- select {
230- case <-openCtx.Done():
231- case <-time.After(time.Second):
232- t.Fatal("session teardown did not cancel the pane process")
233- }
234- d.sessWg.Wait()
235- require.Equal(t, int32(1), pty.closes.Load())
236-}
237-
238-func TestCreatedTabPaneProcessLifetimeIsDaemonRooted(t *testing.T) {
239- factory := &lifetimePTYFactory{}
240- d := newTestDaemon(t, factory, stubClock{})
241- sessCtx, cancelSession := context.WithCancel(t.Context())
242- sess := &session{
243- id: "work",
244- name: "work",
245- ctx: sessCtx,
246- cancel: cancelSession,
247- tabs: []*tab{newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})},
248- }
249- d.mu.Lock()
250- d.sessions[sess.id] = sess
251- d.mu.Unlock()
252-
253- require.NoError(t, d.createTab(sess, domain.Size{Cols: 80, Rows: 24}))
254- created := sess.activeTab()
255- p := created.focusedPane()
256- pty, openCtx := factory.opened(0)
257- require.Equal(t, openCtx, p.ctx)
258- require.NotNil(t, p.cancel)
259-
260- cancelSession()
261- select {
262- case <-openCtx.Done():
263- t.Fatal("session cancellation killed a published tab pane process")
264- default:
265- }
266-
267- created.closeAllPanes()
268- select {
269- case <-openCtx.Done():
270- case <-time.After(time.Second):
271- t.Fatal("tab teardown did not cancel the pane process")
272- }
273- d.sessWg.Wait()
274- require.Equal(t, int32(1), pty.closes.Load())
275-}
276-
277-func TestSplitPaneProcessLifetimeIsDaemonRooted(t *testing.T) {
278- factory := &lifetimePTYFactory{}
279- d := newTestDaemon(t, factory, stubClock{})
280- sessCtx, cancelSession := context.WithCancel(t.Context())
281- tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
282- tb.ctx, tb.cancel = context.WithCancel(sessCtx)
283- sess := &session{id: "work", name: "work", ctx: sessCtx, cancel: cancelSession, tabs: []*tab{tb}}
284- target := tb.focusedPane()
285-
286- require.NoError(t, d.splitPaneAt(sess, tb, target, layout.Right))
287- tb.mu.Lock()
288- p := tb.panes["pane-2"]
289- tb.mu.Unlock()
290- require.NotNil(t, p)
291- pty, openCtx := factory.opened(0)
292- require.Equal(t, openCtx, p.ctx)
293- require.NotNil(t, p.cancel)
294-
295- cancelSession()
296- select {
297- case <-openCtx.Done():
298- t.Fatal("session cancellation killed a published split pane process")
299- default:
300- }
301-
302- tb.closeAllPanes()
303- select {
304- case <-openCtx.Done():
305- case <-time.After(time.Second):
306- t.Fatal("tab teardown did not cancel the split pane process")
307- }
308- d.sessWg.Wait()
309- require.Equal(t, int32(1), pty.closes.Load())
310-}
311-
312-func TestFloatingPaneProcessLifetimeIsDaemonRooted(t *testing.T) {
313- factory := &lifetimePTYFactory{}
314- d := newTestDaemon(t, factory, stubClock{})
315- sessCtx, cancelSession := context.WithCancel(t.Context())
316- tb := newFloatingTestTab(t)
317- tb.ctx, tb.cancel = context.WithCancel(sessCtx)
318- sess := &session{id: "work", name: "work", ctx: sessCtx, cancel: cancelSession, tabs: []*tab{tb}}
319- tb.mu.Lock()
320- generation := tb.beginFloatingWarmLocked(true)
321- tb.mu.Unlock()
322-
323- d.openAndInstallFloating(sess, tb, floatingLaunchSpec{
324- sessionName: "work",
325- size: domain.Size{Cols: 20, Rows: 8},
326- geometry: floatingGeometry{Inner: domain.Rect{Width: 20, Height: 8}},
327- paneStableID: "p_floating",
328- parentCtx: tb.ctx,
329- }, generation)
330- tb.mu.Lock()
331- p := tb.floating.pane
332- tb.mu.Unlock()
333- require.NotNil(t, p)
334- pty, openCtx := factory.opened(0)
335- require.Equal(t, openCtx, p.ctx)
336- require.NotNil(t, p.cancel)
337-
338- cancelSession()
339- select {
340- case <-openCtx.Done():
341- t.Fatal("session cancellation killed a published floating pane process")
342- default:
215+func TestPaneProcessLifetimeIsDaemonRooted(t *testing.T) {
216+ type setupFunc func(*testing.T, *Daemon) (*pane, func(), func())
217+ tests := []struct {
218+ name string
219+ setup setupFunc
220+ }{
221+ {
222+ name: "initial pane",
223+ setup: func(t *testing.T, d *Daemon) (*pane, func(), func()) {
224+ sess, err := createSessionForTest(d, "work", true, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, nil)
225+ require.NoError(t, err)
226+ return sess.activeTab().focusedPane(), sess.cancel, func() {
227+ _ = d.killSession(sess, ports.ReasonSessionKilled, false)
228+ }
229+ },
230+ },
231+ {
232+ name: "created tab",
233+ setup: func(t *testing.T, d *Daemon) (*pane, func(), func()) {
234+ sessCtx, cancelSession := context.WithCancel(t.Context())
235+ base := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
236+ base.ctx, base.cancel = context.WithCancel(sessCtx)
237+ sess := &session{id: "work", name: "work", ctx: sessCtx, cancel: cancelSession, tabs: []*tab{base}}
238+ d.mu.Lock()
239+ d.sessions[sess.id] = sess
240+ d.mu.Unlock()
241+ require.NoError(t, d.createTab(sess, domain.Size{Cols: 80, Rows: 24}))
242+ created := sess.activeTab()
243+ return created.focusedPane(), cancelSession, created.closeAllPanes
244+ },
245+ },
246+ {
247+ name: "split pane",
248+ setup: func(t *testing.T, d *Daemon) (*pane, func(), func()) {
249+ sessCtx, cancelSession := context.WithCancel(t.Context())
250+ tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
251+ tb.ctx, tb.cancel = context.WithCancel(sessCtx)
252+ sess := &session{id: "work", name: "work", ctx: sessCtx, cancel: cancelSession, tabs: []*tab{tb}}
253+ target := tb.focusedPane()
254+ require.NoError(t, d.splitPaneAt(sess, tb, target, layout.Right))
255+ tb.mu.Lock()
256+ p := tb.panes["pane-2"]
257+ tb.mu.Unlock()
258+ require.NotNil(t, p)
259+ return p, cancelSession, tb.closeAllPanes
260+ },
261+ },
262+ {
263+ name: "floating pane",
264+ setup: func(t *testing.T, d *Daemon) (*pane, func(), func()) {
265+ sessCtx, cancelSession := context.WithCancel(t.Context())
266+ tb := newFloatingTestTab(t)
267+ tb.ctx, tb.cancel = context.WithCancel(sessCtx)
268+ sess := &session{id: "work", name: "work", ctx: sessCtx, cancel: cancelSession, tabs: []*tab{tb}}
269+ tb.mu.Lock()
270+ generation := tb.beginFloatingWarmLocked(true)
271+ tb.mu.Unlock()
272+ d.openAndInstallFloating(sess, tb, floatingLaunchSpec{
273+ sessionName: "work", size: domain.Size{Cols: 20, Rows: 8},
274+ geometry: floatingGeometry{Inner: domain.Rect{Width: 20, Height: 8}},
275+ paneStableID: "p_floating", parentCtx: tb.ctx,
276+ }, generation)
277+ tb.mu.Lock()
278+ p := tb.floating.pane
279+ tb.mu.Unlock()
280+ require.NotNil(t, p)
281+ return p, cancelSession, func() { d.teardownFloating(tb, nil) }
282+ },
283+ },
284+ {
285+ name: "restored pane",
286+ setup: func(t *testing.T, d *Daemon) (*pane, func(), func()) {
287+ restoreCtx, cancelRestore := context.WithCancel(t.Context())
288+ sessCtx, cancelSession := context.WithCancel(t.Context())
289+ tabs, err := d.restoreSnapshotTabs(restoreCtx, sessCtx, restoreAcceptanceSession(t, "work"))
290+ require.NoError(t, err)
291+ require.Len(t, tabs, 1)
292+ return tabs[0].focusedPane(), cancelSession, func() {
293+ cancelRestore()
294+ closeRestoredTabs(tabs)
295+ }
296+ },
297+ },
343298 }
344299
345- d.teardownFloating(tb, nil)
346- select {
347- case <-openCtx.Done():
348- case <-time.After(time.Second):
349- t.Fatal("floating teardown did not cancel the pane process")
350- }
351- d.sessWg.Wait()
352- require.Equal(t, int32(1), pty.closes.Load())
353-}
300+ for _, tt := range tests {
301+ t.Run(tt.name, func(t *testing.T) {
302+ factory := &lifetimePTYFactory{}
303+ d := newTestDaemon(t, factory, stubClock{})
304+ p, cancelSession, teardown := tt.setup(t, d)
305+ defer func() {
306+ if teardown != nil {
307+ teardown()
308+ }
309+ }()
354310
355-func TestRestoredPaneProcessLifetimeIsDaemonRooted(t *testing.T) {
356- factory := &lifetimePTYFactory{}
357- d := newTestDaemon(t, factory, stubClock{})
358- sessCtx, cancelSession := context.WithCancel(t.Context())
359- tabs, err := d.restoreSnapshotTabs(t.Context(), sessCtx, restoreAcceptanceSession(t, "work"))
360- require.NoError(t, err)
361- require.Len(t, tabs, 1)
362- p := tabs[0].focusedPane()
363- pty, openCtx := factory.opened(0)
364- require.Equal(t, openCtx, p.ctx)
365- require.NotNil(t, p.cancel)
311+ require.NotNil(t, p)
312+ pty, openCtx := factory.opened(0)
313+ require.NotNil(t, p.ctx)
314+ require.Equal(t, openCtx, p.ctx)
315+ require.NotNil(t, p.cancel)
366316
367- cancelSession()
368- select {
369- case <-openCtx.Done():
370- t.Fatal("session cancellation killed a restored pane process")
371- default:
372- }
317+ cancelSession()
318+ select {
319+ case <-openCtx.Done():
320+ t.Fatal("session cancellation killed a published pane process")
321+ default:
322+ }
373323
374- closeRestoredTabs(tabs)
375- select {
376- case <-openCtx.Done():
377- case <-time.After(time.Second):
378- t.Fatal("restore teardown did not cancel the pane process")
324+ teardown()
325+ teardown = nil
326+ select {
327+ case <-openCtx.Done():
328+ case <-time.After(time.Second):
329+ t.Fatal("teardown did not cancel the pane process")
330+ }
331+ d.sessWg.Wait()
332+ require.Equal(t, int32(1), pty.closes.Load())
333+ })
379334 }
380- require.Equal(t, int32(1), pty.closes.Load())
381335 }
@@ -174,12 +174,18 @@ func TestPaneOpenErrorsReleaseReturnedPTYWithoutPublication(t *testing.T) {
174 }174 }
175 tb.mu.Lock()175 tb.mu.Lock()
176 floating := tb.floating.pane176 floating := tb.floating.pane
177+ var floatingPTY ports.PTY
178+ if floating != nil {
179+ floating.mu.Lock()
180+ floatingPTY = floating.pty
181+ floating.mu.Unlock()
182+ }
177 publishedFailedPTY := false183 publishedFailedPTY := false
178 for _, published := range tb.panes {184 for _, published := range tb.panes {
179 publishedFailedPTY = publishedFailedPTY || published.pty == pty185 publishedFailedPTY = publishedFailedPTY || published.pty == pty
180 }186 }
181 tb.mu.Unlock()187 tb.mu.Unlock()
182- require.NotSame(t, pty, floating)188+ require.NotEqual(t, pty, floatingPTY)
183 require.False(t, publishedFailedPTY, "failed Open must not publish a pane or owner")189 require.False(t, publishedFailedPTY, "failed Open must not publish a pane or owner")
184 }190 }
185 require.Zero(t, pty.readerStarts.Load(), "failed Open must not start a reader")191 require.Zero(t, pty.readerStarts.Load(), "failed Open must not start a reader")
@@ -206,176 +212,124 @@ func (f *paneErrorFactory) Open(ctx context.Context, _ string, _ []string, _ []s
206 return f.pty, f.err212 return f.pty, f.err
207 }213 }
208 214
209-func TestInitialPaneProcessLifetimeIsDaemonRooted(t *testing.T) {215+func TestPaneProcessLifetimeIsDaemonRooted(t *testing.T) {
210- factory := &lifetimePTYFactory{}216+ type setupFunc func(*testing.T, *Daemon) (*pane, func(), func())
211- d := newTestDaemon(t, factory, stubClock{})217+ tests := []struct {
212- sess, err := createSessionForTest(d, "work", true, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, nil)218+ name string
213- require.NoError(t, err)219+ setup setupFunc
214-220+ }{
215- p := sess.activeTab().focusedPane()221+ {
216- pty, openCtx := factory.opened(0)222+ name: "initial pane",
217- require.NotNil(t, p.ctx)223+ setup: func(t *testing.T, d *Daemon) (*pane, func(), func()) {
218- require.Equal(t, openCtx, p.ctx)224+ sess, err := createSessionForTest(d, "work", true, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, nil)
219- require.NotNil(t, p.cancel)225+ require.NoError(t, err)
220-226+ return sess.activeTab().focusedPane(), sess.cancel, func() {
221- sess.cancel()227+ _ = d.killSession(sess, ports.ReasonSessionKilled, false)
222- select {228+ }
223- case <-openCtx.Done():229+ },
224- t.Fatal("session cancellation killed a published pane process")230+ },
225- default:231+ {
226- }232+ name: "created tab",
227-233+ setup: func(t *testing.T, d *Daemon) (*pane, func(), func()) {
228- require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))234+ sessCtx, cancelSession := context.WithCancel(t.Context())
229- select {235+ base := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
230- case <-openCtx.Done():236+ base.ctx, base.cancel = context.WithCancel(sessCtx)
231- case <-time.After(time.Second):237+ sess := &session{id: "work", name: "work", ctx: sessCtx, cancel: cancelSession, tabs: []*tab{base}}
232- t.Fatal("session teardown did not cancel the pane process")238+ d.mu.Lock()
233- }239+ d.sessions[sess.id] = sess
234- d.sessWg.Wait()240+ d.mu.Unlock()
235- require.Equal(t, int32(1), pty.closes.Load())241+ require.NoError(t, d.createTab(sess, domain.Size{Cols: 80, Rows: 24}))
236-}242+ created := sess.activeTab()
237-243+ return created.focusedPane(), cancelSession, created.closeAllPanes
238-func TestCreatedTabPaneProcessLifetimeIsDaemonRooted(t *testing.T) {244+ },
239- factory := &lifetimePTYFactory{}245+ },
240- d := newTestDaemon(t, factory, stubClock{})246+ {
241- sessCtx, cancelSession := context.WithCancel(t.Context())247+ name: "split pane",
242- sess := &session{248+ setup: func(t *testing.T, d *Daemon) (*pane, func(), func()) {
243- id: "work",249+ sessCtx, cancelSession := context.WithCancel(t.Context())
244- name: "work",250+ tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
245- ctx: sessCtx,251+ tb.ctx, tb.cancel = context.WithCancel(sessCtx)
246- cancel: cancelSession,252+ sess := &session{id: "work", name: "work", ctx: sessCtx, cancel: cancelSession, tabs: []*tab{tb}}
247- tabs: []*tab{newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})},253+ target := tb.focusedPane()
248- }254+ require.NoError(t, d.splitPaneAt(sess, tb, target, layout.Right))
249- d.mu.Lock()255+ tb.mu.Lock()
250- d.sessions[sess.id] = sess256+ p := tb.panes["pane-2"]
251- d.mu.Unlock()257+ tb.mu.Unlock()
252-258+ require.NotNil(t, p)
253- require.NoError(t, d.createTab(sess, domain.Size{Cols: 80, Rows: 24}))259+ return p, cancelSession, tb.closeAllPanes
254- created := sess.activeTab()260+ },
255- p := created.focusedPane()261+ },
256- pty, openCtx := factory.opened(0)262+ {
257- require.Equal(t, openCtx, p.ctx)263+ name: "floating pane",
258- require.NotNil(t, p.cancel)264+ setup: func(t *testing.T, d *Daemon) (*pane, func(), func()) {
259-265+ sessCtx, cancelSession := context.WithCancel(t.Context())
260- cancelSession()266+ tb := newFloatingTestTab(t)
261- select {267+ tb.ctx, tb.cancel = context.WithCancel(sessCtx)
262- case <-openCtx.Done():268+ sess := &session{id: "work", name: "work", ctx: sessCtx, cancel: cancelSession, tabs: []*tab{tb}}
263- t.Fatal("session cancellation killed a published tab pane process")269+ tb.mu.Lock()
264- default:270+ generation := tb.beginFloatingWarmLocked(true)
265- }271+ tb.mu.Unlock()
266-272+ d.openAndInstallFloating(sess, tb, floatingLaunchSpec{
267- created.closeAllPanes()273+ sessionName: "work", size: domain.Size{Cols: 20, Rows: 8},
268- select {274+ geometry: floatingGeometry{Inner: domain.Rect{Width: 20, Height: 8}},
269- case <-openCtx.Done():275+ paneStableID: "p_floating", parentCtx: tb.ctx,
270- case <-time.After(time.Second):276+ }, generation)
271- t.Fatal("tab teardown did not cancel the pane process")277+ tb.mu.Lock()
272- }278+ p := tb.floating.pane
273- d.sessWg.Wait()279+ tb.mu.Unlock()
274- require.Equal(t, int32(1), pty.closes.Load())280+ require.NotNil(t, p)
275-}281+ return p, cancelSession, func() { d.teardownFloating(tb, nil) }
276-282+ },
277-func TestSplitPaneProcessLifetimeIsDaemonRooted(t *testing.T) {283+ },
278- factory := &lifetimePTYFactory{}284+ {
279- d := newTestDaemon(t, factory, stubClock{})285+ name: "restored pane",
280- sessCtx, cancelSession := context.WithCancel(t.Context())286+ setup: func(t *testing.T, d *Daemon) (*pane, func(), func()) {
281- tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})287+ restoreCtx, cancelRestore := context.WithCancel(t.Context())
282- tb.ctx, tb.cancel = context.WithCancel(sessCtx)288+ sessCtx, cancelSession := context.WithCancel(t.Context())
283- sess := &session{id: "work", name: "work", ctx: sessCtx, cancel: cancelSession, tabs: []*tab{tb}}289+ tabs, err := d.restoreSnapshotTabs(restoreCtx, sessCtx, restoreAcceptanceSession(t, "work"))
284- target := tb.focusedPane()290+ require.NoError(t, err)
285-291+ require.Len(t, tabs, 1)
286- require.NoError(t, d.splitPaneAt(sess, tb, target, layout.Right))292+ return tabs[0].focusedPane(), cancelSession, func() {
287- tb.mu.Lock()293+ cancelRestore()
288- p := tb.panes["pane-2"]294+ closeRestoredTabs(tabs)
289- tb.mu.Unlock()295+ }
290- require.NotNil(t, p)296+ },
291- pty, openCtx := factory.opened(0)297+ },
292- require.Equal(t, openCtx, p.ctx)
293- require.NotNil(t, p.cancel)
294-
295- cancelSession()
296- select {
297- case <-openCtx.Done():
298- t.Fatal("session cancellation killed a published split pane process")
299- default:
300- }
301-
302- tb.closeAllPanes()
303- select {
304- case <-openCtx.Done():
305- case <-time.After(time.Second):
306- t.Fatal("tab teardown did not cancel the split pane process")
307- }
308- d.sessWg.Wait()
309- require.Equal(t, int32(1), pty.closes.Load())
310-}
311-
312-func TestFloatingPaneProcessLifetimeIsDaemonRooted(t *testing.T) {
313- factory := &lifetimePTYFactory{}
314- d := newTestDaemon(t, factory, stubClock{})
315- sessCtx, cancelSession := context.WithCancel(t.Context())
316- tb := newFloatingTestTab(t)
317- tb.ctx, tb.cancel = context.WithCancel(sessCtx)
318- sess := &session{id: "work", name: "work", ctx: sessCtx, cancel: cancelSession, tabs: []*tab{tb}}
319- tb.mu.Lock()
320- generation := tb.beginFloatingWarmLocked(true)
321- tb.mu.Unlock()
322-
323- d.openAndInstallFloating(sess, tb, floatingLaunchSpec{
324- sessionName: "work",
325- size: domain.Size{Cols: 20, Rows: 8},
326- geometry: floatingGeometry{Inner: domain.Rect{Width: 20, Height: 8}},
327- paneStableID: "p_floating",
328- parentCtx: tb.ctx,
329- }, generation)
330- tb.mu.Lock()
331- p := tb.floating.pane
332- tb.mu.Unlock()
333- require.NotNil(t, p)
334- pty, openCtx := factory.opened(0)
335- require.Equal(t, openCtx, p.ctx)
336- require.NotNil(t, p.cancel)
337-
338- cancelSession()
339- select {
340- case <-openCtx.Done():
341- t.Fatal("session cancellation killed a published floating pane process")
342- default:
343 }298 }
344 299
345- d.teardownFloating(tb, nil)300+ for _, tt := range tests {
346- select {301+ t.Run(tt.name, func(t *testing.T) {
347- case <-openCtx.Done():302+ factory := &lifetimePTYFactory{}
348- case <-time.After(time.Second):303+ d := newTestDaemon(t, factory, stubClock{})
349- t.Fatal("floating teardown did not cancel the pane process")304+ p, cancelSession, teardown := tt.setup(t, d)
350- }305+ defer func() {
351- d.sessWg.Wait()306+ if teardown != nil {
352- require.Equal(t, int32(1), pty.closes.Load())307+ teardown()
353-}308+ }
309+ }()
354 310
355-func TestRestoredPaneProcessLifetimeIsDaemonRooted(t *testing.T) {311+ require.NotNil(t, p)
356- factory := &lifetimePTYFactory{}312+ pty, openCtx := factory.opened(0)
357- d := newTestDaemon(t, factory, stubClock{})313+ require.NotNil(t, p.ctx)
358- sessCtx, cancelSession := context.WithCancel(t.Context())314+ require.Equal(t, openCtx, p.ctx)
359- tabs, err := d.restoreSnapshotTabs(t.Context(), sessCtx, restoreAcceptanceSession(t, "work"))315+ require.NotNil(t, p.cancel)
360- require.NoError(t, err)
361- require.Len(t, tabs, 1)
362- p := tabs[0].focusedPane()
363- pty, openCtx := factory.opened(0)
364- require.Equal(t, openCtx, p.ctx)
365- require.NotNil(t, p.cancel)
366 316
367- cancelSession()317+ cancelSession()
368- select {318+ select {
369- case <-openCtx.Done():319+ case <-openCtx.Done():
370- t.Fatal("session cancellation killed a restored pane process")320+ t.Fatal("session cancellation killed a published pane process")
371- default:321+ default:
372- }322+ }
373 323
374- closeRestoredTabs(tabs)324+ teardown()
375- select {325+ teardown = nil
376- case <-openCtx.Done():326+ select {
377- case <-time.After(time.Second):327+ case <-openCtx.Done():
378- t.Fatal("restore teardown did not cancel the pane process")328+ case <-time.After(time.Second):
329+ t.Fatal("teardown did not cancel the pane process")
330+ }
331+ d.sessWg.Wait()
332+ require.Equal(t, int32(1), pty.closes.Load())
333+ })
379 }334 }
380- require.Equal(t, int32(1), pty.closes.Load())
381 }335 }
M internal/usecase/daemon/picker.go +3 -0
@@ -231,6 +231,9 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
231231 d.invalidateRender(sess, ac, true, "picker.go")
232232 return
233233 }
234+ if len(effects) != 0 && effects[0] != nil {
235+ effects[0].End()
236+ }
234237 d.closePicker(ac)
235238 err := d.commitMovePickerSelection(intent, source, target)
236239 if err != nil {
@@ -231,6 +231,9 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
231 d.invalidateRender(sess, ac, true, "picker.go")231 d.invalidateRender(sess, ac, true, "picker.go")
232 return232 return
233 }233 }
234+ if len(effects) != 0 && effects[0] != nil {
235+ effects[0].End()
236+ }
234 d.closePicker(ac)237 d.closePicker(ac)
235 err := d.commitMovePickerSelection(intent, source, target)238 err := d.commitMovePickerSelection(intent, source, target)
236 if err != nil {239 if err != nil {
M internal/usecase/daemon/render.go +6 -4
@@ -136,6 +136,7 @@ type panePTYEffects struct {
136136 attention bool
137137 responses []byte
138138 clipboards []string
139+ syncCleanup syncTimerCleanup
139140 }
140141
141142 // processPTYData retains the old call shape for resize replay and focused tests,
@@ -159,7 +160,7 @@ func (d *Daemon) processPanePTYData(p *pane, data []byte, bufferDuringApply bool
159160 }
160161 effects := panePTYEffects{lease: p.effectLeaseLocked()}
161162 owner := effects.lease.owner
162- if owner != nil {
163+ if owner != nil && owner.session != nil {
163164 effects.renderCoordinator = owner.session.renderCoordinator()
164165 }
165166 effects.wasSyncing = p.screen.SyncUpdateActive()
@@ -167,13 +168,13 @@ func (d *Daemon) processPanePTYData(p *pane, data []byte, bufferDuringApply bool
167168 p.refreshTerminalTitleLocked()
168169 effects.isSyncing = p.screen.SyncUpdateActive()
169170 effects.completeSyncRead = !effects.wasSyncing && !effects.isSyncing && completedSynchronizedUpdate(data)
170- if effects.wasSyncing != effects.isSyncing && owner != nil {
171+ if effects.wasSyncing != effects.isSyncing && owner != nil && owner.session != nil {
171172 if effects.isSyncing {
172173 syncGen := owner.session.syncGen.Add(1)
173174 p.syncGen = syncGen
174175 if effects.renderCoordinator != nil {
175176 lease := effects.lease
176- effects.renderCoordinator.noteSyncBeginWithRenderability(p, syncGen, func() bool {
177+ effects.syncCleanup = effects.renderCoordinator.beginSyncBatchWithRenderability(p, syncGen, func() bool {
177178 return lease.Current() && d.paneRenderable(owner.session, owner.tab, p)
178179 }, func() {
179180 p.mu.Lock()
@@ -194,6 +195,7 @@ func (d *Daemon) processPanePTYData(p *pane, data []byte, bufferDuringApply bool
194195 effects.clipboards = append([]string(nil), p.ptyClipboards...)
195196 p.ptyClipboards = p.ptyClipboards[:0]
196197 p.mu.Unlock()
198+ effects.syncCleanup.finish()
197199 d.applyPanePTYEffects(p, effects)
198200 }
199201
@@ -203,7 +205,7 @@ func (d *Daemon) processPanePTYData(p *pane, data []byte, bufferDuringApply bool
203205 // bytes that belonged to the old owner.
204206 func (d *Daemon) applyPanePTYEffects(p *pane, effects panePTYEffects) {
205207 owner := effects.lease.owner
206- if owner == nil {
208+ if owner == nil || owner.session == nil || owner.tab == nil {
207209 return
208210 }
209211 if effects.lease.Current() {
@@ -136,6 +136,7 @@ type panePTYEffects struct {
136 attention bool136 attention bool
137 responses []byte137 responses []byte
138 clipboards []string138 clipboards []string
139+ syncCleanup syncTimerCleanup
139 }140 }
140 141
141 // processPTYData retains the old call shape for resize replay and focused tests,142 // processPTYData retains the old call shape for resize replay and focused tests,
@@ -159,7 +160,7 @@ func (d *Daemon) processPanePTYData(p *pane, data []byte, bufferDuringApply bool
159 }160 }
160 effects := panePTYEffects{lease: p.effectLeaseLocked()}161 effects := panePTYEffects{lease: p.effectLeaseLocked()}
161 owner := effects.lease.owner162 owner := effects.lease.owner
162- if owner != nil {163+ if owner != nil && owner.session != nil {
163 effects.renderCoordinator = owner.session.renderCoordinator()164 effects.renderCoordinator = owner.session.renderCoordinator()
164 }165 }
165 effects.wasSyncing = p.screen.SyncUpdateActive()166 effects.wasSyncing = p.screen.SyncUpdateActive()
@@ -167,13 +168,13 @@ func (d *Daemon) processPanePTYData(p *pane, data []byte, bufferDuringApply bool
167 p.refreshTerminalTitleLocked()168 p.refreshTerminalTitleLocked()
168 effects.isSyncing = p.screen.SyncUpdateActive()169 effects.isSyncing = p.screen.SyncUpdateActive()
169 effects.completeSyncRead = !effects.wasSyncing && !effects.isSyncing && completedSynchronizedUpdate(data)170 effects.completeSyncRead = !effects.wasSyncing && !effects.isSyncing && completedSynchronizedUpdate(data)
170- if effects.wasSyncing != effects.isSyncing && owner != nil {171+ if effects.wasSyncing != effects.isSyncing && owner != nil && owner.session != nil {
171 if effects.isSyncing {172 if effects.isSyncing {
172 syncGen := owner.session.syncGen.Add(1)173 syncGen := owner.session.syncGen.Add(1)
173 p.syncGen = syncGen174 p.syncGen = syncGen
174 if effects.renderCoordinator != nil {175 if effects.renderCoordinator != nil {
175 lease := effects.lease176 lease := effects.lease
176- effects.renderCoordinator.noteSyncBeginWithRenderability(p, syncGen, func() bool {177+ effects.syncCleanup = effects.renderCoordinator.beginSyncBatchWithRenderability(p, syncGen, func() bool {
177 return lease.Current() && d.paneRenderable(owner.session, owner.tab, p)178 return lease.Current() && d.paneRenderable(owner.session, owner.tab, p)
178 }, func() {179 }, func() {
179 p.mu.Lock()180 p.mu.Lock()
@@ -194,6 +195,7 @@ func (d *Daemon) processPanePTYData(p *pane, data []byte, bufferDuringApply bool
194 effects.clipboards = append([]string(nil), p.ptyClipboards...)195 effects.clipboards = append([]string(nil), p.ptyClipboards...)
195 p.ptyClipboards = p.ptyClipboards[:0]196 p.ptyClipboards = p.ptyClipboards[:0]
196 p.mu.Unlock()197 p.mu.Unlock()
198+ effects.syncCleanup.finish()
197 d.applyPanePTYEffects(p, effects)199 d.applyPanePTYEffects(p, effects)
198 }200 }
199 201
@@ -203,7 +205,7 @@ func (d *Daemon) processPanePTYData(p *pane, data []byte, bufferDuringApply bool
203 // bytes that belonged to the old owner.205 // bytes that belonged to the old owner.
204 func (d *Daemon) applyPanePTYEffects(p *pane, effects panePTYEffects) {206 func (d *Daemon) applyPanePTYEffects(p *pane, effects panePTYEffects) {
205 owner := effects.lease.owner207 owner := effects.lease.owner
206- if owner == nil {208+ if owner == nil || owner.session == nil || owner.tab == nil {
207 return209 return
208 }210 }
209 if effects.lease.Current() {211 if effects.lease.Current() {
M internal/usecase/daemon/render_coordinator_test.go +2 -2
@@ -1334,7 +1334,7 @@ func TestRenderCoordinatorResizeLaneRejectsStaleToken(t *testing.T) {
13341334 rc.mu.Unlock()
13351335 }
13361336
1337-func TestRenderCoordinatorNilClockRetryCompletesWithoutSynchronousRun(t *testing.T) {
1337+func TestRenderCoordinatorNilClockRetryRunsSynchronously(t *testing.T) {
13381338 rc := newRenderCoordinator(renderCoordinatorOptions{})
13391339 owner := &attachedClient{}
13401340 rc.attach(owner)
@@ -1345,7 +1345,7 @@ func TestRenderCoordinatorNilClockRetryCompletesWithoutSynchronousRun(t *testing
13451345
13461346 called := false
13471347 rc.scheduleResizeRetryForLease(epoch, owner, lease, func() { called = true })
1348- require.False(t, called, "a nil clock is a disabled retry lane, not a synchronous retry")
1348+ require.True(t, called, "a nil clock must run the retry without a timer")
13491349 rc.mu.Lock()
13501350 retryToken := rc.retryLane.token
13511351 rc.mu.Unlock()
@@ -1334,7 +1334,7 @@ func TestRenderCoordinatorResizeLaneRejectsStaleToken(t *testing.T) {
1334 rc.mu.Unlock()1334 rc.mu.Unlock()
1335 }1335 }
1336 1336
1337-func TestRenderCoordinatorNilClockRetryCompletesWithoutSynchronousRun(t *testing.T) {1337+func TestRenderCoordinatorNilClockRetryRunsSynchronously(t *testing.T) {
1338 rc := newRenderCoordinator(renderCoordinatorOptions{})1338 rc := newRenderCoordinator(renderCoordinatorOptions{})
1339 owner := &attachedClient{}1339 owner := &attachedClient{}
1340 rc.attach(owner)1340 rc.attach(owner)
@@ -1345,7 +1345,7 @@ func TestRenderCoordinatorNilClockRetryCompletesWithoutSynchronousRun(t *testing
1345 1345
1346 called := false1346 called := false
1347 rc.scheduleResizeRetryForLease(epoch, owner, lease, func() { called = true })1347 rc.scheduleResizeRetryForLease(epoch, owner, lease, func() { called = true })
1348- require.False(t, called, "a nil clock is a disabled retry lane, not a synchronous retry")1348+ require.True(t, called, "a nil clock must run the retry without a timer")
1349 rc.mu.Lock()1349 rc.mu.Lock()
1350 retryToken := rc.retryLane.token1350 retryToken := rc.retryLane.token
1351 rc.mu.Unlock()1351 rc.mu.Unlock()
M internal/usecase/daemon/render_coordinator_types.go +7 -3
@@ -80,9 +80,10 @@ type resizeRequestMetadata struct {
8080 source *attachedClient
8181 // lease binds every delayed resize/retry callback to the exact attachment
8282 // incarnation that published this request.
83- lease *attachmentLease
84- epoch uint64 // latest requested epoch
85- committed uint64 // latest published epoch
83+ lease *attachmentLease
84+ epoch uint64 // latest requested epoch
85+ committed uint64 // latest published epoch
86+ retryAttempts uint8
8687 }
8788
8889 // renderCoordinatorOptions wires one coordinator instance.
@@ -105,6 +106,9 @@ type renderCoordinatorOptions struct {
105106 // afterSyncGateEvaluated is a package-private deterministic test seam. It
106107 // runs unlocked after visibility predicates and before registry validation.
107108 afterSyncGateEvaluated func()
109+ // onActivateTabAfterResize observes whether activation used a lease-backed
110+ // path; it is nil in production.
111+ onActivateTabAfterResize func(bool)
108112 }
109113
110114 // renderCoordinator fans in producer invalidations for one attached session.
@@ -80,9 +80,10 @@ type resizeRequestMetadata struct {
80 source *attachedClient80 source *attachedClient
81 // lease binds every delayed resize/retry callback to the exact attachment81 // lease binds every delayed resize/retry callback to the exact attachment
82 // incarnation that published this request.82 // incarnation that published this request.
83- lease *attachmentLease83+ lease *attachmentLease
84- epoch uint64 // latest requested epoch84+ epoch uint64 // latest requested epoch
85- committed uint64 // latest published epoch85+ committed uint64 // latest published epoch
86+ retryAttempts uint8
86 }87 }
87 88
88 // renderCoordinatorOptions wires one coordinator instance.89 // renderCoordinatorOptions wires one coordinator instance.
@@ -105,6 +106,9 @@ type renderCoordinatorOptions struct {
105 // afterSyncGateEvaluated is a package-private deterministic test seam. It106 // afterSyncGateEvaluated is a package-private deterministic test seam. It
106 // runs unlocked after visibility predicates and before registry validation.107 // runs unlocked after visibility predicates and before registry validation.
107 afterSyncGateEvaluated func()108 afterSyncGateEvaluated func()
109+ // onActivateTabAfterResize observes whether activation used a lease-backed
110+ // path; it is nil in production.
111+ onActivateTabAfterResize func(bool)
108 }112 }
109 113
110 // renderCoordinator fans in producer invalidations for one attached session.114 // renderCoordinator fans in producer invalidations for one attached session.
M internal/usecase/daemon/render_resize_deadline.go +12 -1
@@ -5,6 +5,8 @@ import (
55 "github.com/bnema/vev/internal/ports"
66 )
77
8+const maxResizeRetryAttempts = 3
9+
810 // Resize deadlines and retries retain coordinator-owned attachment, epoch, and
911 // token validation at every callback effect boundary.
1012 func (c *renderCoordinator) recordResizeRequest(size domain.Size, source *attachedClient) uint64 {
@@ -20,6 +22,7 @@ func (c *renderCoordinator) recordResizeRequestForLease(size domain.Size, source
2022 _, retry := c.retryLane.replaceLocked()
2123 c.resize.epoch++
2224 c.resize.size, c.resize.source, c.resize.lease = size, source, lease
25+ c.resize.retryAttempts = 0
2326 epoch := c.resize.epoch
2427 c.mu.Unlock()
2528 stopDetachedTimer(retry)
@@ -109,9 +112,10 @@ type resizeRetryReservation struct {
109112 func (c *renderCoordinator) reserveResizeRetryForLease(epoch uint64, source *attachedClient, lease *attachmentLease, run func()) (*resizeRetryReservation, bool) {
110113 c.mu.Lock()
111114 defer c.mu.Unlock()
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 {
115+ if !c.leaseCurrentLocked(lease, false) || lease.attachment != source || c.resize.lease != lease || c.resize.source != source || c.resize.epoch != epoch || c.resize.committed != epoch || c.resize.retryAttempts >= maxResizeRetryAttempts {
113116 return nil, false
114117 }
118+ c.resize.retryAttempts++
115119 gen, old := c.retryLane.replaceLocked()
116120 return &resizeRetryReservation{coordinator: c, epoch: epoch, source: source, lease: lease, run: run, generation: gen, old: old, clock: c.opts.clock}, true
117121 }
@@ -123,6 +127,7 @@ func (r *resizeRetryReservation) finish() {
123127 c := r.coordinator
124128 stopDetachedTimer(r.old)
125129 if r.clock == nil {
130+ r.run()
126131 return
127132 }
128133 timer := r.clock.NewTimer(minOutputRenderDeadline)
@@ -164,6 +169,12 @@ func (c *renderCoordinator) scheduleResizeRetryForLease(epoch uint64, source *at
164169 reservation.finish()
165170 }
166171
172+func (c *renderCoordinator) resizeRetryAvailableForLease(epoch uint64, source *attachedClient, lease *attachmentLease) bool {
173+ c.mu.Lock()
174+ defer c.mu.Unlock()
175+ return !c.torndown && c.leaseCurrentLocked(lease, false) && lease.attachment == source && c.resize.lease == lease && c.resize.source == source && c.resize.epoch == epoch && c.resize.committed == epoch && c.resize.retryAttempts < maxResizeRetryAttempts
176+}
177+
167178 func (c *renderCoordinator) retryCurrentForLease(epoch uint64, source *attachedClient, lease *attachmentLease) bool {
168179 c.mu.Lock()
169180 defer c.mu.Unlock()
@@ -5,6 +5,8 @@ import (
5 "github.com/bnema/vev/internal/ports"5 "github.com/bnema/vev/internal/ports"
6 )6 )
7 7
8+const maxResizeRetryAttempts = 3
9+
8 // Resize deadlines and retries retain coordinator-owned attachment, epoch, and10 // Resize deadlines and retries retain coordinator-owned attachment, epoch, and
9 // token validation at every callback effect boundary.11 // token validation at every callback effect boundary.
10 func (c *renderCoordinator) recordResizeRequest(size domain.Size, source *attachedClient) uint64 {12 func (c *renderCoordinator) recordResizeRequest(size domain.Size, source *attachedClient) uint64 {
@@ -20,6 +22,7 @@ func (c *renderCoordinator) recordResizeRequestForLease(size domain.Size, source
20 _, retry := c.retryLane.replaceLocked()22 _, retry := c.retryLane.replaceLocked()
21 c.resize.epoch++23 c.resize.epoch++
22 c.resize.size, c.resize.source, c.resize.lease = size, source, lease24 c.resize.size, c.resize.source, c.resize.lease = size, source, lease
25+ c.resize.retryAttempts = 0
23 epoch := c.resize.epoch26 epoch := c.resize.epoch
24 c.mu.Unlock()27 c.mu.Unlock()
25 stopDetachedTimer(retry)28 stopDetachedTimer(retry)
@@ -109,9 +112,10 @@ type resizeRetryReservation struct {
109 func (c *renderCoordinator) reserveResizeRetryForLease(epoch uint64, source *attachedClient, lease *attachmentLease, run func()) (*resizeRetryReservation, bool) {112 func (c *renderCoordinator) reserveResizeRetryForLease(epoch uint64, source *attachedClient, lease *attachmentLease, run func()) (*resizeRetryReservation, bool) {
110 c.mu.Lock()113 c.mu.Lock()
111 defer c.mu.Unlock()114 defer c.mu.Unlock()
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 {115+ if !c.leaseCurrentLocked(lease, false) || lease.attachment != source || c.resize.lease != lease || c.resize.source != source || c.resize.epoch != epoch || c.resize.committed != epoch || c.resize.retryAttempts >= maxResizeRetryAttempts {
113 return nil, false116 return nil, false
114 }117 }
118+ c.resize.retryAttempts++
115 gen, old := c.retryLane.replaceLocked()119 gen, old := c.retryLane.replaceLocked()
116 return &resizeRetryReservation{coordinator: c, epoch: epoch, source: source, lease: lease, run: run, generation: gen, old: old, clock: c.opts.clock}, true120 return &resizeRetryReservation{coordinator: c, epoch: epoch, source: source, lease: lease, run: run, generation: gen, old: old, clock: c.opts.clock}, true
117 }121 }
@@ -123,6 +127,7 @@ func (r *resizeRetryReservation) finish() {
123 c := r.coordinator127 c := r.coordinator
124 stopDetachedTimer(r.old)128 stopDetachedTimer(r.old)
125 if r.clock == nil {129 if r.clock == nil {
130+ r.run()
126 return131 return
127 }132 }
128 timer := r.clock.NewTimer(minOutputRenderDeadline)133 timer := r.clock.NewTimer(minOutputRenderDeadline)
@@ -164,6 +169,12 @@ func (c *renderCoordinator) scheduleResizeRetryForLease(epoch uint64, source *at
164 reservation.finish()169 reservation.finish()
165 }170 }
166 171
172+func (c *renderCoordinator) resizeRetryAvailableForLease(epoch uint64, source *attachedClient, lease *attachmentLease) bool {
173+ c.mu.Lock()
174+ defer c.mu.Unlock()
175+ return !c.torndown && c.leaseCurrentLocked(lease, false) && lease.attachment == source && c.resize.lease == lease && c.resize.source == source && c.resize.epoch == epoch && c.resize.committed == epoch && c.resize.retryAttempts < maxResizeRetryAttempts
176+}
177+
167 func (c *renderCoordinator) retryCurrentForLease(epoch uint64, source *attachedClient, lease *attachmentLease) bool {178 func (c *renderCoordinator) retryCurrentForLease(epoch uint64, source *attachedClient, lease *attachmentLease) bool {
168 c.mu.Lock()179 c.mu.Lock()
169 defer c.mu.Unlock()180 defer c.mu.Unlock()
M internal/usecase/daemon/resize_publication.go +14 -7
@@ -24,7 +24,11 @@ func (p resizeCommitPublication) current() bool {
2424 if p.attachment == nil {
2525 return true
2626 }
27- if p.role.effect == nil || p.role.effect.ended.Load() || p.role.sess != p.session ||
27+ // Attached resize publication is admitted only through a live role ticket.
28+ if p.role.effect == nil {
29+ return false
30+ }
31+ if p.role.effect.ended.Load() || p.role.sess != p.session ||
2832 p.role.ac != p.attachment || p.role.role != attachmentActive || p.role.lease != p.lease ||
2933 p.role.generation != p.attachment.roleGeneration.Load() ||
3034 !p.attachment.transportSnapshotCurrent(p.role.transport) ||
@@ -56,6 +60,9 @@ func (p resizeCommitPublication) emit() bool {
5660 // the canonical rule never acquires an attachment lock while a pane fence is
5761 // held. Observer callbacks run only after both lock families are released.
5862 func (d *Daemon) publishResizeCommit(members []resizeMember, sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64, ticket *roleEffectTicket, size domain.Size) bool {
63+ if ac != nil && ticket == nil {
64+ return false
65+ }
5966 d.observeBeforeResizeOwnerPostEffect(resizeOwnerPostCommitPublication)
6067 if ac != nil {
6168 ac.sendMu.Lock()
@@ -130,7 +137,7 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *
130137 if !d.publishResizeCommit(result.members, sess, ac, lease, epoch, ticket, snap.size) {
131138 return false
132139 }
133- if len(result.failed) != 0 {
140+ if len(result.failed) != 0 && rc.resizeRetryAvailableForLease(epoch, ac, lease) {
134141 var retry *resizeRetryReservation
135142 if !d.publishResizeOwnerPostEffect(result.failed, resizeOwnerPostRetrySchedule, func() {
136143 retry, _ = rc.reserveResizeRetryForLease(epoch, ac, lease, func() {
@@ -173,7 +180,7 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *at
173180 tabs := make([]*tab, 0, len(members))
174181 seen := make(map[*tab]struct{}, len(members))
175182 for _, member := range members {
176- if member.tab == nil || member.isFloating || !resizeMemberOwnerCurrentLocked(&member) {
183+ if member.tab == nil || member.isFloating || !resizeMemberOwnerCurrent(&member) {
177184 continue
178185 }
179186 if _, ok := seen[member.tab]; ok {
@@ -233,7 +240,7 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *at
233240 tb.mu.Unlock()
234241 }
235242 for _, member := range members {
236- if !member.isFloating || member.tab == nil || member.pane == nil || !resizeMemberOwnerCurrentLocked(&member) {
243+ if !member.isFloating || member.tab == nil || member.pane == nil || !resizeMemberOwnerCurrent(&member) {
237244 continue
238245 }
239246 if !rc.retryCurrentForLease(epoch, ac, lease) {
@@ -246,7 +253,7 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *at
246253 currentSlot := member.tab.floating.state == floatingVisible &&
247254 member.tab.floating.generation == member.floatingGeneration &&
248255 member.tab.floating.pane == member.pane &&
249- resizeMemberOwnerCurrentLocked(&member)
256+ resizeMemberOwnerCurrent(&member)
250257 retryPending := false
251258 if currentSlot {
252259 member.pane.mu.Lock()
@@ -268,7 +275,7 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *at
268275 stillCurrent := member.tab.floating.state == floatingVisible &&
269276 member.tab.floating.generation == member.floatingGeneration &&
270277 member.tab.floating.pane == member.pane &&
271- resizeMemberOwnerCurrentLocked(&member)
278+ resizeMemberOwnerCurrent(&member)
272279 member.tab.mu.Unlock()
273280 if stillCurrent && len(result.failed) == 0 {
274281 member.pane.mu.Lock()
@@ -276,7 +283,7 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *at
276283 member.pane.mu.Unlock()
277284 }
278285 }
279- if len(failed) != 0 {
286+ if len(failed) != 0 && rc.resizeRetryAvailableForLease(epoch, ac, lease) {
280287 var retry *resizeRetryReservation
281288 if !d.publishResizeOwnerPostEffect(failed, resizeOwnerPostRetrySchedule, func() {
282289 retry, _ = rc.reserveResizeRetryForLease(epoch, ac, lease, func() {
@@ -24,7 +24,11 @@ func (p resizeCommitPublication) current() bool {
24 if p.attachment == nil {24 if p.attachment == nil {
25 return true25 return true
26 }26 }
27- if p.role.effect == nil || p.role.effect.ended.Load() || p.role.sess != p.session ||27+ // Attached resize publication is admitted only through a live role ticket.
28+ if p.role.effect == nil {
29+ return false
30+ }
31+ if p.role.effect.ended.Load() || p.role.sess != p.session ||
28 p.role.ac != p.attachment || p.role.role != attachmentActive || p.role.lease != p.lease ||32 p.role.ac != p.attachment || p.role.role != attachmentActive || p.role.lease != p.lease ||
29 p.role.generation != p.attachment.roleGeneration.Load() ||33 p.role.generation != p.attachment.roleGeneration.Load() ||
30 !p.attachment.transportSnapshotCurrent(p.role.transport) ||34 !p.attachment.transportSnapshotCurrent(p.role.transport) ||
@@ -56,6 +60,9 @@ func (p resizeCommitPublication) emit() bool {
56 // the canonical rule never acquires an attachment lock while a pane fence is60 // the canonical rule never acquires an attachment lock while a pane fence is
57 // held. Observer callbacks run only after both lock families are released.61 // held. Observer callbacks run only after both lock families are released.
58 func (d *Daemon) publishResizeCommit(members []resizeMember, sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64, ticket *roleEffectTicket, size domain.Size) bool {62 func (d *Daemon) publishResizeCommit(members []resizeMember, sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64, ticket *roleEffectTicket, size domain.Size) bool {
63+ if ac != nil && ticket == nil {
64+ return false
65+ }
59 d.observeBeforeResizeOwnerPostEffect(resizeOwnerPostCommitPublication)66 d.observeBeforeResizeOwnerPostEffect(resizeOwnerPostCommitPublication)
60 if ac != nil {67 if ac != nil {
61 ac.sendMu.Lock()68 ac.sendMu.Lock()
@@ -130,7 +137,7 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *
130 if !d.publishResizeCommit(result.members, sess, ac, lease, epoch, ticket, snap.size) {137 if !d.publishResizeCommit(result.members, sess, ac, lease, epoch, ticket, snap.size) {
131 return false138 return false
132 }139 }
133- if len(result.failed) != 0 {140+ if len(result.failed) != 0 && rc.resizeRetryAvailableForLease(epoch, ac, lease) {
134 var retry *resizeRetryReservation141 var retry *resizeRetryReservation
135 if !d.publishResizeOwnerPostEffect(result.failed, resizeOwnerPostRetrySchedule, func() {142 if !d.publishResizeOwnerPostEffect(result.failed, resizeOwnerPostRetrySchedule, func() {
136 retry, _ = rc.reserveResizeRetryForLease(epoch, ac, lease, func() {143 retry, _ = rc.reserveResizeRetryForLease(epoch, ac, lease, func() {
@@ -173,7 +180,7 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *at
173 tabs := make([]*tab, 0, len(members))180 tabs := make([]*tab, 0, len(members))
174 seen := make(map[*tab]struct{}, len(members))181 seen := make(map[*tab]struct{}, len(members))
175 for _, member := range members {182 for _, member := range members {
176- if member.tab == nil || member.isFloating || !resizeMemberOwnerCurrentLocked(&member) {183+ if member.tab == nil || member.isFloating || !resizeMemberOwnerCurrent(&member) {
177 continue184 continue
178 }185 }
179 if _, ok := seen[member.tab]; ok {186 if _, ok := seen[member.tab]; ok {
@@ -233,7 +240,7 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *at
233 tb.mu.Unlock()240 tb.mu.Unlock()
234 }241 }
235 for _, member := range members {242 for _, member := range members {
236- if !member.isFloating || member.tab == nil || member.pane == nil || !resizeMemberOwnerCurrentLocked(&member) {243+ if !member.isFloating || member.tab == nil || member.pane == nil || !resizeMemberOwnerCurrent(&member) {
237 continue244 continue
238 }245 }
239 if !rc.retryCurrentForLease(epoch, ac, lease) {246 if !rc.retryCurrentForLease(epoch, ac, lease) {
@@ -246,7 +253,7 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *at
246 currentSlot := member.tab.floating.state == floatingVisible &&253 currentSlot := member.tab.floating.state == floatingVisible &&
247 member.tab.floating.generation == member.floatingGeneration &&254 member.tab.floating.generation == member.floatingGeneration &&
248 member.tab.floating.pane == member.pane &&255 member.tab.floating.pane == member.pane &&
249- resizeMemberOwnerCurrentLocked(&member)256+ resizeMemberOwnerCurrent(&member)
250 retryPending := false257 retryPending := false
251 if currentSlot {258 if currentSlot {
252 member.pane.mu.Lock()259 member.pane.mu.Lock()
@@ -268,7 +275,7 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *at
268 stillCurrent := member.tab.floating.state == floatingVisible &&275 stillCurrent := member.tab.floating.state == floatingVisible &&
269 member.tab.floating.generation == member.floatingGeneration &&276 member.tab.floating.generation == member.floatingGeneration &&
270 member.tab.floating.pane == member.pane &&277 member.tab.floating.pane == member.pane &&
271- resizeMemberOwnerCurrentLocked(&member)278+ resizeMemberOwnerCurrent(&member)
272 member.tab.mu.Unlock()279 member.tab.mu.Unlock()
273 if stillCurrent && len(result.failed) == 0 {280 if stillCurrent && len(result.failed) == 0 {
274 member.pane.mu.Lock()281 member.pane.mu.Lock()
@@ -276,7 +283,7 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *at
276 member.pane.mu.Unlock()283 member.pane.mu.Unlock()
277 }284 }
278 }285 }
279- if len(failed) != 0 {286+ if len(failed) != 0 && rc.resizeRetryAvailableForLease(epoch, ac, lease) {
280 var retry *resizeRetryReservation287 var retry *resizeRetryReservation
281 if !d.publishResizeOwnerPostEffect(failed, resizeOwnerPostRetrySchedule, func() {288 if !d.publishResizeOwnerPostEffect(failed, resizeOwnerPostRetrySchedule, func() {
282 retry, _ = rc.reserveResizeRetryForLease(epoch, ac, lease, func() {289 retry, _ = rc.reserveResizeRetryForLease(epoch, ac, lease, func() {
M internal/usecase/daemon/resize_retry.go +1 -1
@@ -23,7 +23,7 @@ func (t acceptedTabLayoutRetryToken) current() bool {
2323 }
2424 for i := range t.members {
2525 member := &t.members[i]
26- if member.session != t.session || member.tab != t.tab || !resizeMemberOwnerCurrentLocked(member) {
26+ if member.session != t.session || member.tab != t.tab || !resizeMemberOwnerCurrent(member) {
2727 return false
2828 }
2929 }
@@ -23,7 +23,7 @@ func (t acceptedTabLayoutRetryToken) current() bool {
23 }23 }
24 for i := range t.members {24 for i := range t.members {
25 member := &t.members[i]25 member := &t.members[i]
26- if member.session != t.session || member.tab != t.tab || !resizeMemberOwnerCurrentLocked(member) {26+ if member.session != t.session || member.tab != t.tab || !resizeMemberOwnerCurrent(member) {
27 return false27 return false
28 }28 }
29 }29 }
M internal/usecase/daemon/resume.go +26 -3
@@ -3,11 +3,14 @@ package daemon
33 import (
44 "crypto/rand"
55 "encoding/binary"
6+ "errors"
67
78 "github.com/bnema/vev/internal/domain"
89 "github.com/bnema/vev/internal/ports"
910 )
1011
12+var errResumeTokenLifecycleRace = errors.New("resume token lifecycle race")
13+
1114 func newResumeToken() uint64 {
1215 var b [8]byte
1316 if _, err := rand.Read(b[:]); err != nil {
@@ -70,9 +73,17 @@ func (d *Daemon) parkAttachmentAs(sess *session, ac *attachedClient, role attach
7073 sess.mu.Unlock()
7174 }
7275 token := ac.resumeToken
76+ var (
77+ oldRetirement *parkedAttachmentRetirement
78+ oldSame *parkedAttachment
79+ )
7380 if old := d.parked[token]; old != nil {
74- old.timer.Stop()
75- old.closeDone()
81+ retirement := d.retireParkedAttachmentLocked(token, old)
82+ if old.ac == ac {
83+ oldSame = old
84+ } else {
85+ oldRetirement = &retirement
86+ }
7687 }
7788 grace := d.resumeParkGrace
7889 timer := d.clock.NewTimer(grace)
@@ -80,6 +91,15 @@ func (d *Daemon) parkAttachmentAs(sess *session, ac *attachedClient, role attach
8091 ac.parked = true
8192 d.parked[token] = parked
8293 d.mu.Unlock()
94+ if oldRetirement != nil {
95+ finishParkedAttachmentRetirements([]parkedAttachmentRetirement{*oldRetirement})
96+ }
97+ if oldSame != nil {
98+ if oldSame.timer != nil {
99+ oldSame.timer.Stop()
100+ }
101+ oldSame.closeDone()
102+ }
83103 d.log.Info("client parked for resume", "session", sess.name, "grace", grace)
84104
85105 go func(token uint64, parked *parkedAttachment) {
@@ -206,7 +226,10 @@ func (d *Daemon) resumeParked(h ports.Hello, tr ports.Transport, sz domain.Size)
206226 return nil, nil, false, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
207227 }
208228 if d.parked[h.ResumeToken] != parked {
209- return nil, nil, false, nil
229+ if d.sessions[parked.sess.id] == parked.sess {
230+ return nil, nil, false, errResumeTokenLifecycleRace
231+ }
232+ return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
210233 }
211234 return d.resumeParkedLocked(h, tr, sz)
212235 }
@@ -3,11 +3,14 @@ package daemon
3 import (3 import (
4 "crypto/rand"4 "crypto/rand"
5 "encoding/binary"5 "encoding/binary"
6+ "errors"
6 7
7 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
8 "github.com/bnema/vev/internal/ports"9 "github.com/bnema/vev/internal/ports"
9 )10 )
10 11
12+var errResumeTokenLifecycleRace = errors.New("resume token lifecycle race")
13+
11 func newResumeToken() uint64 {14 func newResumeToken() uint64 {
12 var b [8]byte15 var b [8]byte
13 if _, err := rand.Read(b[:]); err != nil {16 if _, err := rand.Read(b[:]); err != nil {
@@ -70,9 +73,17 @@ func (d *Daemon) parkAttachmentAs(sess *session, ac *attachedClient, role attach
70 sess.mu.Unlock()73 sess.mu.Unlock()
71 }74 }
72 token := ac.resumeToken75 token := ac.resumeToken
76+ var (
77+ oldRetirement *parkedAttachmentRetirement
78+ oldSame *parkedAttachment
79+ )
73 if old := d.parked[token]; old != nil {80 if old := d.parked[token]; old != nil {
74- old.timer.Stop()81+ retirement := d.retireParkedAttachmentLocked(token, old)
75- old.closeDone()82+ if old.ac == ac {
83+ oldSame = old
84+ } else {
85+ oldRetirement = &retirement
86+ }
76 }87 }
77 grace := d.resumeParkGrace88 grace := d.resumeParkGrace
78 timer := d.clock.NewTimer(grace)89 timer := d.clock.NewTimer(grace)
@@ -80,6 +91,15 @@ func (d *Daemon) parkAttachmentAs(sess *session, ac *attachedClient, role attach
80 ac.parked = true91 ac.parked = true
81 d.parked[token] = parked92 d.parked[token] = parked
82 d.mu.Unlock()93 d.mu.Unlock()
94+ if oldRetirement != nil {
95+ finishParkedAttachmentRetirements([]parkedAttachmentRetirement{*oldRetirement})
96+ }
97+ if oldSame != nil {
98+ if oldSame.timer != nil {
99+ oldSame.timer.Stop()
100+ }
101+ oldSame.closeDone()
102+ }
83 d.log.Info("client parked for resume", "session", sess.name, "grace", grace)103 d.log.Info("client parked for resume", "session", sess.name, "grace", grace)
84 104
85 go func(token uint64, parked *parkedAttachment) {105 go func(token uint64, parked *parkedAttachment) {
@@ -206,7 +226,10 @@ func (d *Daemon) resumeParked(h ports.Hello, tr ports.Transport, sz domain.Size)
206 return nil, nil, false, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}226 return nil, nil, false, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
207 }227 }
208 if d.parked[h.ResumeToken] != parked {228 if d.parked[h.ResumeToken] != parked {
209- return nil, nil, false, nil229+ if d.sessions[parked.sess.id] == parked.sess {
230+ return nil, nil, false, errResumeTokenLifecycleRace
231+ }
232+ return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
210 }233 }
211 return d.resumeParkedLocked(h, tr, sz)234 return d.resumeParkedLocked(h, tr, sz)
212 }235 }
M internal/usecase/daemon/role_effect_freeze.go +21 -37
@@ -86,55 +86,39 @@ type roleTransportInterrupt struct {
8686 transport transportSnapshot
8787 }
8888
89-// freezeRoleEffectGates establishes transition priority in immutable attachment
90-// order, then drains earlier admissions. It must be called without daemon,
91-// routing, session, coordinator, send, overlay, router, or pane locks held.
92-func freezeRoleEffectGates(clients ...*attachedClient) frozenRoleEffectGates {
93- return freezeRoleEffectGatesInterrupting(nil, clients...)
94-}
95-
96-// freezeRoleEffectGatesInterrupting may close an exact captured transport only
97-// while an earlier admitted effect is inside that transport's Send. Closing and
98-// all drain waits happen without the gate mutex or an architecture lock held.
99-func freezeRoleEffectGatesInterrupting(interrupts []roleTransportInterrupt, clients ...*attachedClient) frozenRoleEffectGates {
100- return freezeRoleEffectGatesInterruptingObserved(interrupts, nil, clients...)
101-}
102-
103-func freezeRoleEffectGatesInterruptingObserved(interrupts []roleTransportInterrupt, afterFrozen func(*attachedClient), clients ...*attachedClient) frozenRoleEffectGates {
104- return freezeRoleEffectGatesInterruptingObservedUntil(interrupts, nil, afterFrozen, clients...)
105-}
106-
107-// tryFreezeRoleEffectGatesInterruptingObserved establishes transition priority
108-// without waiting behind a gate already owned by another lifecycle operation.
109-// Move reservations use this form: killSession freezes role gates before it
110-// waits for the reservation, so waiting here would invert the two protocols.
111-func tryFreezeRoleEffectGatesInterruptingObserved(interrupts []roleTransportInterrupt, afterFrozen func(*attachedClient), clients ...*attachedClient) frozenRoleEffectGates {
112- return freezeRoleEffectGatesInterruptingObservedMode(interrupts, nil, afterFrozen, true, clients...)
89+// roleEffectFreezeOptions controls one role-gate freeze operation. A freeze
90+// must be called without daemon, routing, session, coordinator, send, overlay,
91+// router, or pane locks held.
92+type roleEffectFreezeOptions struct {
93+ interrupts []roleTransportInterrupt
94+ done func() <-chan struct{}
95+ afterFrozen func(*attachedClient)
96+ nonblocking bool
11397 }
11498
115-// freezeRoleEffectGatesInterruptingObservedUntil uses done as one overall bound
116-// for ordered acquisition and drain. Acquisition is all-or-nothing: if the
117-// bound expires behind another owner, only gates acquired by this call are
118-// rolled back, in reverse order. Once the complete set is owned, a terminal
119-// teardown may proceed when done closes during drain; late tickets then retire
120-// against an invalidated capability without pinning the session owner
121-// indefinitely.
122-func freezeRoleEffectGatesInterruptingObservedUntil(interrupts []roleTransportInterrupt, done func() <-chan struct{}, afterFrozen func(*attachedClient), clients ...*attachedClient) frozenRoleEffectGates {
123- return freezeRoleEffectGatesInterruptingObservedMode(interrupts, done, afterFrozen, false, clients...)
99+// freezeRoleEffectGates establishes transition priority in immutable attachment
100+// order, then drains earlier admissions.
101+func freezeRoleEffectGates(clients ...*attachedClient) frozenRoleEffectGates {
102+ return freezeRoleEffectGatesWith(roleEffectFreezeOptions{}, clients...)
124103 }
125104
126-func freezeRoleEffectGatesInterruptingObservedMode(interrupts []roleTransportInterrupt, done func() <-chan struct{}, afterFrozen func(*attachedClient), nonblocking bool, clients ...*attachedClient) frozenRoleEffectGates {
105+// freezeRoleEffectGatesWith uses done as one overall bound for ordered
106+// acquisition and drain. Acquisition is all-or-nothing: if the bound expires
107+// behind another owner, only gates acquired by this call are rolled back, in
108+// reverse order. nonblocking is used by move reservations to avoid inverting
109+// the move/teardown protocols.
110+func freezeRoleEffectGatesWith(options roleEffectFreezeOptions, clients ...*attachedClient) frozenRoleEffectGates {
127111 ordered := orderedRoleEffectClients(clients)
128- frozen, acquired := acquireRoleEffectGates(ordered, done, afterFrozen, nonblocking)
112+ frozen, acquired := acquireRoleEffectGates(ordered, options.done, options.afterFrozen, options.nonblocking)
129113 if !acquired {
130114 return frozenRoleEffectGates{}
131115 }
132116
133- interruptByClient := roleTransportInterruptsByClient(interrupts)
117+ interruptByClient := roleTransportInterruptsByClient(options.interrupts)
134118 frozen.interruptions = make(map[*attachedClient]transportSnapshot, len(interruptByClient))
135119 interruptFrozenRoleTransports(ordered, interruptByClient)
136120 frozen.acquired = true
137- frozen.drained = drainFrozenRoleEffects(ordered, interruptByClient, frozen.interruptions, done)
121+ frozen.drained = drainFrozenRoleEffects(ordered, interruptByClient, frozen.interruptions, options.done)
138122 return frozen
139123 }
140124
@@ -86,55 +86,39 @@ type roleTransportInterrupt struct {
86 transport transportSnapshot86 transport transportSnapshot
87 }87 }
88 88
89-// freezeRoleEffectGates establishes transition priority in immutable attachment89+// roleEffectFreezeOptions controls one role-gate freeze operation. A freeze
90-// order, then drains earlier admissions. It must be called without daemon,90+// must be called without daemon, routing, session, coordinator, send, overlay,
91-// routing, session, coordinator, send, overlay, router, or pane locks held.91+// router, or pane locks held.
92-func freezeRoleEffectGates(clients ...*attachedClient) frozenRoleEffectGates {92+type roleEffectFreezeOptions struct {
93- return freezeRoleEffectGatesInterrupting(nil, clients...)93+ interrupts []roleTransportInterrupt
94-}94+ done func() <-chan struct{}
95-95+ afterFrozen func(*attachedClient)
96-// freezeRoleEffectGatesInterrupting may close an exact captured transport only96+ nonblocking bool
97-// while an earlier admitted effect is inside that transport's Send. Closing and
98-// all drain waits happen without the gate mutex or an architecture lock held.
99-func freezeRoleEffectGatesInterrupting(interrupts []roleTransportInterrupt, clients ...*attachedClient) frozenRoleEffectGates {
100- return freezeRoleEffectGatesInterruptingObserved(interrupts, nil, clients...)
101-}
102-
103-func freezeRoleEffectGatesInterruptingObserved(interrupts []roleTransportInterrupt, afterFrozen func(*attachedClient), clients ...*attachedClient) frozenRoleEffectGates {
104- return freezeRoleEffectGatesInterruptingObservedUntil(interrupts, nil, afterFrozen, clients...)
105-}
106-
107-// tryFreezeRoleEffectGatesInterruptingObserved establishes transition priority
108-// without waiting behind a gate already owned by another lifecycle operation.
109-// Move reservations use this form: killSession freezes role gates before it
110-// waits for the reservation, so waiting here would invert the two protocols.
111-func tryFreezeRoleEffectGatesInterruptingObserved(interrupts []roleTransportInterrupt, afterFrozen func(*attachedClient), clients ...*attachedClient) frozenRoleEffectGates {
112- return freezeRoleEffectGatesInterruptingObservedMode(interrupts, nil, afterFrozen, true, clients...)
113 }97 }
114 98
115-// freezeRoleEffectGatesInterruptingObservedUntil uses done as one overall bound99+// freezeRoleEffectGates establishes transition priority in immutable attachment
116-// for ordered acquisition and drain. Acquisition is all-or-nothing: if the100+// order, then drains earlier admissions.
117-// bound expires behind another owner, only gates acquired by this call are101+func freezeRoleEffectGates(clients ...*attachedClient) frozenRoleEffectGates {
118-// rolled back, in reverse order. Once the complete set is owned, a terminal102+ return freezeRoleEffectGatesWith(roleEffectFreezeOptions{}, clients...)
119-// teardown may proceed when done closes during drain; late tickets then retire
120-// against an invalidated capability without pinning the session owner
121-// indefinitely.
122-func freezeRoleEffectGatesInterruptingObservedUntil(interrupts []roleTransportInterrupt, done func() <-chan struct{}, afterFrozen func(*attachedClient), clients ...*attachedClient) frozenRoleEffectGates {
123- return freezeRoleEffectGatesInterruptingObservedMode(interrupts, done, afterFrozen, false, clients...)
124 }103 }
125 104
126-func freezeRoleEffectGatesInterruptingObservedMode(interrupts []roleTransportInterrupt, done func() <-chan struct{}, afterFrozen func(*attachedClient), nonblocking bool, clients ...*attachedClient) frozenRoleEffectGates {105+// freezeRoleEffectGatesWith uses done as one overall bound for ordered
106+// acquisition and drain. Acquisition is all-or-nothing: if the bound expires
107+// behind another owner, only gates acquired by this call are rolled back, in
108+// reverse order. nonblocking is used by move reservations to avoid inverting
109+// the move/teardown protocols.
110+func freezeRoleEffectGatesWith(options roleEffectFreezeOptions, clients ...*attachedClient) frozenRoleEffectGates {
127 ordered := orderedRoleEffectClients(clients)111 ordered := orderedRoleEffectClients(clients)
128- frozen, acquired := acquireRoleEffectGates(ordered, done, afterFrozen, nonblocking)112+ frozen, acquired := acquireRoleEffectGates(ordered, options.done, options.afterFrozen, options.nonblocking)
129 if !acquired {113 if !acquired {
130 return frozenRoleEffectGates{}114 return frozenRoleEffectGates{}
131 }115 }
132 116
133- interruptByClient := roleTransportInterruptsByClient(interrupts)117+ interruptByClient := roleTransportInterruptsByClient(options.interrupts)
134 frozen.interruptions = make(map[*attachedClient]transportSnapshot, len(interruptByClient))118 frozen.interruptions = make(map[*attachedClient]transportSnapshot, len(interruptByClient))
135 interruptFrozenRoleTransports(ordered, interruptByClient)119 interruptFrozenRoleTransports(ordered, interruptByClient)
136 frozen.acquired = true120 frozen.acquired = true
137- frozen.drained = drainFrozenRoleEffects(ordered, interruptByClient, frozen.interruptions, done)121+ frozen.drained = drainFrozenRoleEffects(ordered, interruptByClient, frozen.interruptions, options.done)
138 return frozen122 return frozen
139 }123 }
140 124
M internal/usecase/daemon/role_effect_gate.go +9 -0
@@ -82,6 +82,15 @@ func (g *roleEffectGate) immutableOrder() uint64 {
8282 return g.order.Load()
8383 }
8484
85+func (g *roleEffectGate) inFlightCount() uint64 {
86+ if g == nil {
87+ return 0
88+ }
89+ g.mu.Lock()
90+ defer g.mu.Unlock()
91+ return g.inFlight
92+}
93+
8594 // roleEffectTicket reserves the exact published capability until End. End is
8695 // idempotent so error paths can safely defer it before doing any observable I/O.
8796 type roleEffectTicket struct {
@@ -82,6 +82,15 @@ func (g *roleEffectGate) immutableOrder() uint64 {
82 return g.order.Load()82 return g.order.Load()
83 }83 }
84 84
85+func (g *roleEffectGate) inFlightCount() uint64 {
86+ if g == nil {
87+ return 0
88+ }
89+ g.mu.Lock()
90+ defer g.mu.Unlock()
91+ return g.inFlight
92+}
93+
85 // roleEffectTicket reserves the exact published capability until End. End is94 // roleEffectTicket reserves the exact published capability until End. End is
86 // idempotent so error paths can safely defer it before doing any observable I/O.95 // idempotent so error paths can safely defer it before doing any observable I/O.
87 type roleEffectTicket struct {96 type roleEffectTicket struct {
M internal/usecase/daemon/role_effect_gate_test.go +28 -9
@@ -16,6 +16,14 @@ import (
1616 "github.com/bnema/vev/pkg/renderer"
1717 )
1818
19+func releaseTestGate(t *testing.T, ch chan struct{}) func() {
20+ t.Helper()
21+ var once sync.Once
22+ release := func() { once.Do(func() { close(ch) }) }
23+ t.Cleanup(release)
24+ return release
25+}
26+
1927 type blockedRenderReplacementTransport struct {
2028 sendEntered chan struct{}
2129 closed chan struct{}
@@ -824,11 +832,13 @@ func TestJumpAttentionDoesNotMutateSourceAfterInitiatorReplacement(t *testing.T)
824832
825833 admissionEnded := make(chan struct{})
826834 releaseAction := make(chan struct{})
835+ release := releaseTestGate(t, releaseAction)
836+ var admissionEndedOnce sync.Once
827837 d.afterActionRoleEffectEnded = func(action string) {
828838 if action != "jump-attention" {
829839 return
830840 }
831- close(admissionEnded)
841+ admissionEndedOnce.Do(func() { close(admissionEnded) })
832842 <-releaseAction
833843 }
834844 actionDone := make(chan struct{})
@@ -848,7 +858,7 @@ func TestJumpAttentionDoesNotMutateSourceAfterInitiatorReplacement(t *testing.T)
848858 sess.mu.Lock()
849859 sess.active = 0
850860 sess.mu.Unlock()
851- close(releaseAction)
861+ release()
852862 <-actionDone
853863
854864 require.Equal(t, 0, activeTabIndex(sess), "the replaced initiator mutated source focus after losing its role")
@@ -906,9 +916,11 @@ func TestJumpAttentionHandoffRevalidatesInitiatorAfterAdmissionEnds(t *testing.T
906916
907917 admissionEnded := make(chan struct{})
908918 releaseAction := make(chan struct{})
919+ release := releaseTestGate(t, releaseAction)
920+ var admissionEndedOnce sync.Once
909921 d.afterActionRoleEffectEnded = func(action string) {
910922 if action == "jump-attention" {
911- close(admissionEnded)
923+ admissionEndedOnce.Do(func() { close(admissionEnded) })
912924 <-releaseAction
913925 }
914926 }
@@ -926,7 +938,7 @@ func TestJumpAttentionHandoffRevalidatesInitiatorAfterAdmissionEnds(t *testing.T
926938 expectedTransport: next.transportSnapshot(), ready: true,
927939 })
928940 require.NoError(t, err)
929- close(releaseAction)
941+ release()
930942 <-actionDone
931943
932944 require.Equal(t, 0, activeTabIndex(target), "stale handoff changed target focus")
@@ -951,11 +963,13 @@ func TestPickerDeleteDoesNotDeleteSourceAfterInitiatorReplacement(t *testing.T)
951963
952964 admissionEnded := make(chan struct{})
953965 releaseAction := make(chan struct{})
966+ release := releaseTestGate(t, releaseAction)
967+ var admissionEndedOnce sync.Once
954968 d.afterActionRoleEffectEnded = func(action string) {
955969 if action != "picker-delete" {
956970 return
957971 }
958- close(admissionEnded)
972+ admissionEndedOnce.Do(func() { close(admissionEnded) })
959973 <-releaseAction
960974 }
961975 actionDone := make(chan struct{})
@@ -972,7 +986,7 @@ func TestPickerDeleteDoesNotDeleteSourceAfterInitiatorReplacement(t *testing.T)
972986 expectedTransport: next.transportSnapshot(), ready: true,
973987 })
974988 require.NoError(t, err)
975- close(releaseAction)
989+ release()
976990 <-actionDone
977991
978992 d.mu.Lock()
@@ -1010,9 +1024,11 @@ func TestPickerDeleteOtherSessionRevalidatesInitiatorAfterAdmissionEnds(t *testi
10101024
10111025 admissionEnded := make(chan struct{})
10121026 releaseAction := make(chan struct{})
1027+ release := releaseTestGate(t, releaseAction)
1028+ var admissionEndedOnce sync.Once
10131029 d.afterActionRoleEffectEnded = func(action string) {
10141030 if action == "picker-delete" {
1015- close(admissionEnded)
1031+ admissionEndedOnce.Do(func() { close(admissionEnded) })
10161032 <-releaseAction
10171033 }
10181034 }
@@ -1035,7 +1051,7 @@ func TestPickerDeleteOtherSessionRevalidatesInitiatorAfterAdmissionEnds(t *testi
10351051 expectedTransport: next.transportSnapshot(), ready: true,
10361052 })
10371053 require.NoError(t, err)
1038- close(releaseAction)
1054+ release()
10391055 <-actionDone
10401056 d.mu.Lock()
10411057 _, targetStillRegistered = d.sessions[target.id]
@@ -1191,7 +1207,10 @@ func TestRoleEffectGateAdmittedSnatchedPanelFinishesBeforePromotion(t *testing.T
11911207 close(release)
11921208 <-effectDone
11931209 require.NoError(t, <-transitionDone)
1194- require.Equal(t, wantSize, old.size)
1210+ old.sendMu.Lock()
1211+ gotSize := old.size
1212+ old.sendMu.Unlock()
1213+ require.Equal(t, wantSize, gotSize)
11951214 }
11961215
11971216 func TestRoleEffectGateAdmittedFirstPaintFinishesBeforeReplacement(t *testing.T) {
@@ -16,6 +16,14 @@ import (
16 "github.com/bnema/vev/pkg/renderer"16 "github.com/bnema/vev/pkg/renderer"
17 )17 )
18 18
19+func releaseTestGate(t *testing.T, ch chan struct{}) func() {
20+ t.Helper()
21+ var once sync.Once
22+ release := func() { once.Do(func() { close(ch) }) }
23+ t.Cleanup(release)
24+ return release
25+}
26+
19 type blockedRenderReplacementTransport struct {27 type blockedRenderReplacementTransport struct {
20 sendEntered chan struct{}28 sendEntered chan struct{}
21 closed chan struct{}29 closed chan struct{}
@@ -824,11 +832,13 @@ func TestJumpAttentionDoesNotMutateSourceAfterInitiatorReplacement(t *testing.T)
824 832
825 admissionEnded := make(chan struct{})833 admissionEnded := make(chan struct{})
826 releaseAction := make(chan struct{})834 releaseAction := make(chan struct{})
835+ release := releaseTestGate(t, releaseAction)
836+ var admissionEndedOnce sync.Once
827 d.afterActionRoleEffectEnded = func(action string) {837 d.afterActionRoleEffectEnded = func(action string) {
828 if action != "jump-attention" {838 if action != "jump-attention" {
829 return839 return
830 }840 }
831- close(admissionEnded)841+ admissionEndedOnce.Do(func() { close(admissionEnded) })
832 <-releaseAction842 <-releaseAction
833 }843 }
834 actionDone := make(chan struct{})844 actionDone := make(chan struct{})
@@ -848,7 +858,7 @@ func TestJumpAttentionDoesNotMutateSourceAfterInitiatorReplacement(t *testing.T)
848 sess.mu.Lock()858 sess.mu.Lock()
849 sess.active = 0859 sess.active = 0
850 sess.mu.Unlock()860 sess.mu.Unlock()
851- close(releaseAction)861+ release()
852 <-actionDone862 <-actionDone
853 863
854 require.Equal(t, 0, activeTabIndex(sess), "the replaced initiator mutated source focus after losing its role")864 require.Equal(t, 0, activeTabIndex(sess), "the replaced initiator mutated source focus after losing its role")
@@ -906,9 +916,11 @@ func TestJumpAttentionHandoffRevalidatesInitiatorAfterAdmissionEnds(t *testing.T
906 916
907 admissionEnded := make(chan struct{})917 admissionEnded := make(chan struct{})
908 releaseAction := make(chan struct{})918 releaseAction := make(chan struct{})
919+ release := releaseTestGate(t, releaseAction)
920+ var admissionEndedOnce sync.Once
909 d.afterActionRoleEffectEnded = func(action string) {921 d.afterActionRoleEffectEnded = func(action string) {
910 if action == "jump-attention" {922 if action == "jump-attention" {
911- close(admissionEnded)923+ admissionEndedOnce.Do(func() { close(admissionEnded) })
912 <-releaseAction924 <-releaseAction
913 }925 }
914 }926 }
@@ -926,7 +938,7 @@ func TestJumpAttentionHandoffRevalidatesInitiatorAfterAdmissionEnds(t *testing.T
926 expectedTransport: next.transportSnapshot(), ready: true,938 expectedTransport: next.transportSnapshot(), ready: true,
927 })939 })
928 require.NoError(t, err)940 require.NoError(t, err)
929- close(releaseAction)941+ release()
930 <-actionDone942 <-actionDone
931 943
932 require.Equal(t, 0, activeTabIndex(target), "stale handoff changed target focus")944 require.Equal(t, 0, activeTabIndex(target), "stale handoff changed target focus")
@@ -951,11 +963,13 @@ func TestPickerDeleteDoesNotDeleteSourceAfterInitiatorReplacement(t *testing.T)
951 963
952 admissionEnded := make(chan struct{})964 admissionEnded := make(chan struct{})
953 releaseAction := make(chan struct{})965 releaseAction := make(chan struct{})
966+ release := releaseTestGate(t, releaseAction)
967+ var admissionEndedOnce sync.Once
954 d.afterActionRoleEffectEnded = func(action string) {968 d.afterActionRoleEffectEnded = func(action string) {
955 if action != "picker-delete" {969 if action != "picker-delete" {
956 return970 return
957 }971 }
958- close(admissionEnded)972+ admissionEndedOnce.Do(func() { close(admissionEnded) })
959 <-releaseAction973 <-releaseAction
960 }974 }
961 actionDone := make(chan struct{})975 actionDone := make(chan struct{})
@@ -972,7 +986,7 @@ func TestPickerDeleteDoesNotDeleteSourceAfterInitiatorReplacement(t *testing.T)
972 expectedTransport: next.transportSnapshot(), ready: true,986 expectedTransport: next.transportSnapshot(), ready: true,
973 })987 })
974 require.NoError(t, err)988 require.NoError(t, err)
975- close(releaseAction)989+ release()
976 <-actionDone990 <-actionDone
977 991
978 d.mu.Lock()992 d.mu.Lock()
@@ -1010,9 +1024,11 @@ func TestPickerDeleteOtherSessionRevalidatesInitiatorAfterAdmissionEnds(t *testi
1010 1024
1011 admissionEnded := make(chan struct{})1025 admissionEnded := make(chan struct{})
1012 releaseAction := make(chan struct{})1026 releaseAction := make(chan struct{})
1027+ release := releaseTestGate(t, releaseAction)
1028+ var admissionEndedOnce sync.Once
1013 d.afterActionRoleEffectEnded = func(action string) {1029 d.afterActionRoleEffectEnded = func(action string) {
1014 if action == "picker-delete" {1030 if action == "picker-delete" {
1015- close(admissionEnded)1031+ admissionEndedOnce.Do(func() { close(admissionEnded) })
1016 <-releaseAction1032 <-releaseAction
1017 }1033 }
1018 }1034 }
@@ -1035,7 +1051,7 @@ func TestPickerDeleteOtherSessionRevalidatesInitiatorAfterAdmissionEnds(t *testi
1035 expectedTransport: next.transportSnapshot(), ready: true,1051 expectedTransport: next.transportSnapshot(), ready: true,
1036 })1052 })
1037 require.NoError(t, err)1053 require.NoError(t, err)
1038- close(releaseAction)1054+ release()
1039 <-actionDone1055 <-actionDone
1040 d.mu.Lock()1056 d.mu.Lock()
1041 _, targetStillRegistered = d.sessions[target.id]1057 _, targetStillRegistered = d.sessions[target.id]
@@ -1191,7 +1207,10 @@ func TestRoleEffectGateAdmittedSnatchedPanelFinishesBeforePromotion(t *testing.T
1191 close(release)1207 close(release)
1192 <-effectDone1208 <-effectDone
1193 require.NoError(t, <-transitionDone)1209 require.NoError(t, <-transitionDone)
1194- require.Equal(t, wantSize, old.size)1210+ old.sendMu.Lock()
1211+ gotSize := old.size
1212+ old.sendMu.Unlock()
1213+ require.Equal(t, wantSize, gotSize)
1195 }1214 }
1196 1215
1197 func TestRoleEffectGateAdmittedFirstPaintFinishesBeforeReplacement(t *testing.T) {1216 func TestRoleEffectGateAdmittedFirstPaintFinishesBeforeReplacement(t *testing.T) {
M internal/usecase/daemon/session.go +7 -5
@@ -265,6 +265,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
265265 }
266266 tb := newTabWithStableID(tabStableID, paneStableID, pty, tbSize)
267267 if !lifetime.publish(tb.focusedPane()) {
268+ lifetime.abort()
268269 _ = pty.Close()
269270 closeTabs(tabs)
270271 return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session: shell failed to start", lifetime.ctx.Err())
@@ -406,7 +407,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
406407 })
407408 if err != nil {
408409 _ = d.killSession(newSess, ports.ReasonSessionKilled, true)
409- return errors.New("client detached")
410+ return err
410411 }
411412
412413 d.touchMRU(newSess)
@@ -517,6 +518,7 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
517518 if !lifetime.publish(p) {
518519 sess.mu.Unlock()
519520 d.mu.Unlock()
521+ lifetime.abort()
520522 _ = pty.Close()
521523 return errors.New("daemon: session closed")
522524 }
@@ -706,7 +708,7 @@ func (d *Daemon) detachIfRoleCurrentUntil(token attachmentRoleToken, done func()
706708 if token.sess == nil || token.ac == nil || token.role != attachmentActive || token.transport.transport == nil {
707709 return false
708710 }
709- frozen := freezeRoleEffectGatesInterruptingObservedUntil(nil, done, nil, token.ac)
711+ frozen := freezeRoleEffectGatesWith(roleEffectFreezeOptions{done: done}, token.ac)
710712 defer frozen.unfreeze()
711713 if !frozen.acquired || !frozen.drained {
712714 return false
@@ -1331,11 +1333,11 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
13311333 drainDone = drainDeadline.Done
13321334 defer drainDeadline.stop()
13331335 }
1334- frozen := freezeRoleEffectGatesInterruptingObservedUntil(participants.interrupts, drainDone, func(ac *attachedClient) {
1336+ frozen := freezeRoleEffectGatesWith(roleEffectFreezeOptions{interrupts: participants.interrupts, done: drainDone, afterFrozen: func(ac *attachedClient) {
13351337 if d.afterRoleEffectGateFrozen != nil {
13361338 d.afterRoleEffectGateFrozen(action, ac)
13371339 }
1338- }, participants.roleGates...)
1340+ }}, participants.roleGates...)
13391341 defer func() { frozen.unfreeze() }()
13401342 if !frozen.acquired {
13411343 // The acquisition helper has already rolled back exactly the gates it
@@ -1490,7 +1492,7 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
14901492 stoppedName := sess.name
14911493 stoppedCwd := sess.cwd
14921494 createdAt := sess.createdAt
1493- tabNames := sess.persistRecordLocked(time.Now().UnixNano()).TabNames
1495+ tabNames := sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))).TabNames
14941496 ephemeral := sess.ephemeral
14951497 unlockCoordinators()
14961498 unlockSessions()
@@ -265,6 +265,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
265 }265 }
266 tb := newTabWithStableID(tabStableID, paneStableID, pty, tbSize)266 tb := newTabWithStableID(tabStableID, paneStableID, pty, tbSize)
267 if !lifetime.publish(tb.focusedPane()) {267 if !lifetime.publish(tb.focusedPane()) {
268+ lifetime.abort()
268 _ = pty.Close()269 _ = pty.Close()
269 closeTabs(tabs)270 closeTabs(tabs)
270 return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session: shell failed to start", lifetime.ctx.Err())271 return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session: shell failed to start", lifetime.ctx.Err())
@@ -406,7 +407,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
406 })407 })
407 if err != nil {408 if err != nil {
408 _ = d.killSession(newSess, ports.ReasonSessionKilled, true)409 _ = d.killSession(newSess, ports.ReasonSessionKilled, true)
409- return errors.New("client detached")410+ return err
410 }411 }
411 412
412 d.touchMRU(newSess)413 d.touchMRU(newSess)
@@ -517,6 +518,7 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
517 if !lifetime.publish(p) {518 if !lifetime.publish(p) {
518 sess.mu.Unlock()519 sess.mu.Unlock()
519 d.mu.Unlock()520 d.mu.Unlock()
521+ lifetime.abort()
520 _ = pty.Close()522 _ = pty.Close()
521 return errors.New("daemon: session closed")523 return errors.New("daemon: session closed")
522 }524 }
@@ -706,7 +708,7 @@ func (d *Daemon) detachIfRoleCurrentUntil(token attachmentRoleToken, done func()
706 if token.sess == nil || token.ac == nil || token.role != attachmentActive || token.transport.transport == nil {708 if token.sess == nil || token.ac == nil || token.role != attachmentActive || token.transport.transport == nil {
707 return false709 return false
708 }710 }
709- frozen := freezeRoleEffectGatesInterruptingObservedUntil(nil, done, nil, token.ac)711+ frozen := freezeRoleEffectGatesWith(roleEffectFreezeOptions{done: done}, token.ac)
710 defer frozen.unfreeze()712 defer frozen.unfreeze()
711 if !frozen.acquired || !frozen.drained {713 if !frozen.acquired || !frozen.drained {
712 return false714 return false
@@ -1331,11 +1333,11 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
1331 drainDone = drainDeadline.Done1333 drainDone = drainDeadline.Done
1332 defer drainDeadline.stop()1334 defer drainDeadline.stop()
1333 }1335 }
1334- frozen := freezeRoleEffectGatesInterruptingObservedUntil(participants.interrupts, drainDone, func(ac *attachedClient) {1336+ frozen := freezeRoleEffectGatesWith(roleEffectFreezeOptions{interrupts: participants.interrupts, done: drainDone, afterFrozen: func(ac *attachedClient) {
1335 if d.afterRoleEffectGateFrozen != nil {1337 if d.afterRoleEffectGateFrozen != nil {
1336 d.afterRoleEffectGateFrozen(action, ac)1338 d.afterRoleEffectGateFrozen(action, ac)
1337 }1339 }
1338- }, participants.roleGates...)1340+ }}, participants.roleGates...)
1339 defer func() { frozen.unfreeze() }()1341 defer func() { frozen.unfreeze() }()
1340 if !frozen.acquired {1342 if !frozen.acquired {
1341 // The acquisition helper has already rolled back exactly the gates it1343 // The acquisition helper has already rolled back exactly the gates it
@@ -1490,7 +1492,7 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
1490 stoppedName := sess.name1492 stoppedName := sess.name
1491 stoppedCwd := sess.cwd1493 stoppedCwd := sess.cwd
1492 createdAt := sess.createdAt1494 createdAt := sess.createdAt
1493- tabNames := sess.persistRecordLocked(time.Now().UnixNano()).TabNames1495+ tabNames := sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))).TabNames
1494 ephemeral := sess.ephemeral1496 ephemeral := sess.ephemeral
1495 unlockCoordinators()1497 unlockCoordinators()
1496 unlockSessions()1498 unlockSessions()
M internal/usecase/daemon/session_move_locator_test.go +1 -0
@@ -41,4 +41,5 @@ func TestSessionMoveLocatorSynchronizesMutableNameRead(t *testing.T) {
4141 locator := sessionMoveLocator(sess)
4242 require.Equal(t, sess.id, locator.ID)
4343 require.Equal(t, sess.incarnation, locator.Incarnation)
44+ require.Equal(t, "session-9999", locator.Name)
4445 }
@@ -41,4 +41,5 @@ func TestSessionMoveLocatorSynchronizesMutableNameRead(t *testing.T) {
41 locator := sessionMoveLocator(sess)41 locator := sessionMoveLocator(sess)
42 require.Equal(t, sess.id, locator.ID)42 require.Equal(t, sess.id, locator.ID)
43 require.Equal(t, sess.incarnation, locator.Incarnation)43 require.Equal(t, sess.incarnation, locator.Incarnation)
44+ require.Equal(t, "session-9999", locator.Name)
44 }45 }
M internal/usecase/daemon/session_test.go +1 -0
@@ -304,6 +304,7 @@ func TestAttachReplaceKeepsOldClientSnatched(t *testing.T) {
304304 trB, sendsB, releaseB := newConn(t, mustHello(ports.IntentAttach, "0", domain.Size{Cols: 80, Rows: 24}))
305305 hg.Go(func() { d.handleConn(trB) })
306306 awaitFrame(t, sendsB, ports.MsgWelcome)
307+ d.attachmentCleanupWg.Wait()
307308
308309 // B is now the sole active client while A remains a live snatched member.
309310 sess.mu.Lock()
@@ -304,6 +304,7 @@ func TestAttachReplaceKeepsOldClientSnatched(t *testing.T) {
304 trB, sendsB, releaseB := newConn(t, mustHello(ports.IntentAttach, "0", domain.Size{Cols: 80, Rows: 24}))304 trB, sendsB, releaseB := newConn(t, mustHello(ports.IntentAttach, "0", domain.Size{Cols: 80, Rows: 24}))
305 hg.Go(func() { d.handleConn(trB) })305 hg.Go(func() { d.handleConn(trB) })
306 awaitFrame(t, sendsB, ports.MsgWelcome)306 awaitFrame(t, sendsB, ports.MsgWelcome)
307+ d.attachmentCleanupWg.Wait()
307 308
308 // B is now the sole active client while A remains a live snatched member.309 // B is now the sole active client while A remains a live snatched member.
309 sess.mu.Lock()310 sess.mu.Lock()
M internal/usecase/daemon/snatched_input_test.go +2 -2
@@ -215,7 +215,7 @@ func TestSnatchedInputStrictActions(t *testing.T) {
215215 func TestBlockedSnatchedQuitDeadlineRetiresOnlyExactWaiter(t *testing.T) {
216216 clock := &signalClock{timers: make(chan *signalTimer, 2)}
217217 waitingTransport := newBlockedSnatchedQuitTransport()
218- defer waitingTransport.Close()
218+ defer func() { _ = waitingTransport.Close() }()
219219 activeTransport := &closeTrackingTransport{}
220220 d, sess, waiting, active := newAtomicReclaimFixture(t, clock, waitingTransport, activeTransport)
221221
@@ -275,7 +275,7 @@ func TestBlockedSnatchedQuitDeadlineRetiresOnlyExactWaiter(t *testing.T) {
275275 func TestBlockedSnatchedQuitCleanupRejectsReclaimedTransportGeneration(t *testing.T) {
276276 clock := &signalClock{timers: make(chan *signalTimer, 2)}
277277 staleTransport := newBlockedSnatchedQuitTransport()
278- defer staleTransport.Close()
278+ defer func() { _ = staleTransport.Close() }()
279279 ownerTransport := &closeTrackingTransport{}
280280 d, sess, waiting, owner := newAtomicReclaimFixture(t, clock, staleTransport, ownerTransport)
281281
@@ -215,7 +215,7 @@ func TestSnatchedInputStrictActions(t *testing.T) {
215 func TestBlockedSnatchedQuitDeadlineRetiresOnlyExactWaiter(t *testing.T) {215 func TestBlockedSnatchedQuitDeadlineRetiresOnlyExactWaiter(t *testing.T) {
216 clock := &signalClock{timers: make(chan *signalTimer, 2)}216 clock := &signalClock{timers: make(chan *signalTimer, 2)}
217 waitingTransport := newBlockedSnatchedQuitTransport()217 waitingTransport := newBlockedSnatchedQuitTransport()
218- defer waitingTransport.Close()218+ defer func() { _ = waitingTransport.Close() }()
219 activeTransport := &closeTrackingTransport{}219 activeTransport := &closeTrackingTransport{}
220 d, sess, waiting, active := newAtomicReclaimFixture(t, clock, waitingTransport, activeTransport)220 d, sess, waiting, active := newAtomicReclaimFixture(t, clock, waitingTransport, activeTransport)
221 221
@@ -275,7 +275,7 @@ func TestBlockedSnatchedQuitDeadlineRetiresOnlyExactWaiter(t *testing.T) {
275 func TestBlockedSnatchedQuitCleanupRejectsReclaimedTransportGeneration(t *testing.T) {275 func TestBlockedSnatchedQuitCleanupRejectsReclaimedTransportGeneration(t *testing.T) {
276 clock := &signalClock{timers: make(chan *signalTimer, 2)}276 clock := &signalClock{timers: make(chan *signalTimer, 2)}
277 staleTransport := newBlockedSnatchedQuitTransport()277 staleTransport := newBlockedSnatchedQuitTransport()
278- defer staleTransport.Close()278+ defer func() { _ = staleTransport.Close() }()
279 ownerTransport := &closeTrackingTransport{}279 ownerTransport := &closeTrackingTransport{}
280 d, sess, waiting, owner := newAtomicReclaimFixture(t, clock, staleTransport, ownerTransport)280 d, sess, waiting, owner := newAtomicReclaimFixture(t, clock, staleTransport, ownerTransport)
281 281
M internal/usecase/daemon/snatched_reclaim_test.go +3 -1
@@ -143,7 +143,9 @@ func TestReclaimSnatchedSwapsOwnerLeaseRebasesAndAppliesTheme(t *testing.T) {
143143
144144 d.attachmentCleanupWg.Wait()
145145 require.Equal(t, attachmentSnatched, sess.attachmentRole(active))
146- require.False(t, active.transport().(*closeTrackingTransport).Closed())
146+ activeTransport, ok := active.transport().(*closeTrackingTransport)
147+ require.True(t, ok, "active transport has unexpected type")
148+ require.False(t, activeTransport.Closed())
147149 require.Equal(t, domain.Size{Cols: 80, Rows: 24}, requester.size)
148150 }
149151
@@ -143,7 +143,9 @@ func TestReclaimSnatchedSwapsOwnerLeaseRebasesAndAppliesTheme(t *testing.T) {
143 143
144 d.attachmentCleanupWg.Wait()144 d.attachmentCleanupWg.Wait()
145 require.Equal(t, attachmentSnatched, sess.attachmentRole(active))145 require.Equal(t, attachmentSnatched, sess.attachmentRole(active))
146- require.False(t, active.transport().(*closeTrackingTransport).Closed())146+ activeTransport, ok := active.transport().(*closeTrackingTransport)
147+ require.True(t, ok, "active transport has unexpected type")
148+ require.False(t, activeTransport.Closed())
147 require.Equal(t, domain.Size{Cols: 80, Rows: 24}, requester.size)149 require.Equal(t, domain.Size{Cols: 80, Rows: 24}, requester.size)
148 }150 }
149 151
M internal/usecase/daemon/snatched_render_test.go +1 -1
@@ -146,7 +146,7 @@ func TestSendSnatchedPanelTimeoutClosesCapturedTransportOnly(t *testing.T) {
146146 timer.ch <- time.Time{}
147147
148148 require.ErrorIs(t, awaitTestValue(t, result, "snatched send did not time out"), errSendTimedOut)
149- defer old.Close() // Release the blocked worker if the assertion below fails.
149+ defer func() { _ = old.Close() }() // Release the blocked worker if the assertion below fails.
150150 require.True(t, old.Closed(), "timed-out captured transport was not closed")
151151 require.False(t, fresh.Closed(), "replacement transport must remain open")
152152
@@ -146,7 +146,7 @@ func TestSendSnatchedPanelTimeoutClosesCapturedTransportOnly(t *testing.T) {
146 timer.ch <- time.Time{}146 timer.ch <- time.Time{}
147 147
148 require.ErrorIs(t, awaitTestValue(t, result, "snatched send did not time out"), errSendTimedOut)148 require.ErrorIs(t, awaitTestValue(t, result, "snatched send did not time out"), errSendTimedOut)
149- defer old.Close() // Release the blocked worker if the assertion below fails.149+ defer func() { _ = old.Close() }() // Release the blocked worker if the assertion below fails.
150 require.True(t, old.Closed(), "timed-out captured transport was not closed")150 require.True(t, old.Closed(), "timed-out captured transport was not closed")
151 require.False(t, fresh.Closed(), "replacement transport must remain open")151 require.False(t, fresh.Closed(), "replacement transport must remain open")
152 152
M internal/usecase/daemon/status_test.go +4 -2
@@ -528,7 +528,8 @@ func TestAttachClientAppliesForcedThemeBeforeMsgTheme(t *testing.T) {
528528 }
529529 tr, _ := newCapturingTransport(t)
530530
531- ac, _, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
531+ ac, _, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
532+ require.NoError(t, err)
532533
533534 require.Equal(t, tc.want, ac.getAppliedTheme().Raw)
534535 assertSessionDefaultColors(t, sess, tc.want.Foreground, tc.want.Background)
@@ -717,7 +718,8 @@ func TestClientGoneResetDoesNotClobberNewlyAttachedClient(t *testing.T) {
717718 require.True(t, sess.detachIfCurrent(ac))
718719
719720 tr, _ := newCapturingTransport(t)
720- newAC, _, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
721+ newAC, _, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
722+ require.NoError(t, err)
721723 d.applyTheme(sess, newAC, ports.Theme{
722724 HasForeground: true, Foreground: renderer.RGB{R: 20, G: 21, B: 22},
723725 HasBackground: true, Background: renderer.RGB{R: 23, G: 24, B: 25},
@@ -528,7 +528,8 @@ func TestAttachClientAppliesForcedThemeBeforeMsgTheme(t *testing.T) {
528 }528 }
529 tr, _ := newCapturingTransport(t)529 tr, _ := newCapturingTransport(t)
530 530
531- ac, _, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})531+ ac, _, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
532+ require.NoError(t, err)
532 533
533 require.Equal(t, tc.want, ac.getAppliedTheme().Raw)534 require.Equal(t, tc.want, ac.getAppliedTheme().Raw)
534 assertSessionDefaultColors(t, sess, tc.want.Foreground, tc.want.Background)535 assertSessionDefaultColors(t, sess, tc.want.Foreground, tc.want.Background)
@@ -717,7 +718,8 @@ func TestClientGoneResetDoesNotClobberNewlyAttachedClient(t *testing.T) {
717 require.True(t, sess.detachIfCurrent(ac))718 require.True(t, sess.detachIfCurrent(ac))
718 719
719 tr, _ := newCapturingTransport(t)720 tr, _ := newCapturingTransport(t)
720- newAC, _, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})721+ newAC, _, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
722+ require.NoError(t, err)
721 d.applyTheme(sess, newAC, ports.Theme{723 d.applyTheme(sess, newAC, ports.Theme{
722 HasForeground: true, Foreground: renderer.RGB{R: 20, G: 21, B: 22},724 HasForeground: true, Foreground: renderer.RGB{R: 20, G: 21, B: 22},
723 HasBackground: true, Background: renderer.RGB{R: 23, G: 24, B: 25},725 HasBackground: true, Background: renderer.RGB{R: 23, G: 24, B: 25},
M internal/usecase/daemon/transactional_resize.go +21 -13
@@ -88,7 +88,7 @@ func (d *Daemon) applyPreparedTabMembers(plan *preparedTabLayout) {
8888 p := member.pane
8989 p.resizeMu.Lock()
9090 p.mu.Lock()
91- if !resizeMemberOwnerCurrentLocked(member) {
91+ if !resizeMemberOwnerCurrent(member) {
9292 member.retry = p.owner.Load() != nil
9393 p.resizeRetry = member.retry
9494 p.mu.Unlock()
@@ -111,7 +111,7 @@ func (d *Daemon) applyPreparedTabMembers(plan *preparedTabLayout) {
111111 } else if err := pty.Resize(rectSize(member.rect)); err != nil {
112112 d.log.Warn("pty resize failed", "err", err)
113113 p.mu.Lock()
114- current := resizeMemberOwnerCurrentLocked(member)
114+ current := resizeMemberOwnerCurrent(member)
115115 p.resizeRetry = true
116116 p.mu.Unlock()
117117 d.replayResizePending(member.session, member.tab, p, false, member.rect)
@@ -121,7 +121,7 @@ func (d *Daemon) applyPreparedTabMembers(plan *preparedTabLayout) {
121121 }
122122 } else {
123123 p.mu.Lock()
124- current := resizeMemberOwnerCurrentLocked(member)
124+ current := resizeMemberOwnerCurrent(member)
125125 if !current && p.owner.Load() != nil {
126126 p.resizeRetry = true
127127 member.retry = true
@@ -134,7 +134,7 @@ func (d *Daemon) applyPreparedTabMembers(plan *preparedTabLayout) {
134134 }
135135 }
136136
137-func resizeMemberOwnerCurrentLocked(member *resizeMember) bool {
137+func resizeMemberOwnerCurrent(member *resizeMember) bool {
138138 if member == nil || member.pane == nil {
139139 return false
140140 }
@@ -154,7 +154,7 @@ func validatePreparedTabLayoutLocked(tb *tab, plan *preparedTabLayout) bool {
154154 }
155155 for i := range plan.members {
156156 member := &plan.members[i]
157- if tb.panes[member.pane.id] != member.pane || !resizeMemberOwnerCurrentLocked(member) {
157+ if tb.panes[member.pane.id] != member.pane || !resizeMemberOwnerCurrent(member) {
158158 return false
159159 }
160160 }
@@ -163,7 +163,7 @@ func validatePreparedTabLayoutLocked(tb *tab, plan *preparedTabLayout) bool {
163163
164164 func resizeMembersOwnerCurrent(members []resizeMember) bool {
165165 for i := range members {
166- if !resizeMemberOwnerCurrentLocked(&members[i]) {
166+ if !resizeMemberOwnerCurrent(&members[i]) {
167167 return false
168168 }
169169 }
@@ -201,12 +201,19 @@ func commitPreparedTabLayoutLocked(plan *preparedTabLayout) bool {
201201 for i := range plan.members {
202202 member := &plan.members[i]
203203 member.pane.mu.Lock()
204- if !resizeMemberOwnerCurrentLocked(member) {
204+ current := resizeMemberOwnerCurrent(member)
205+ if !current {
205206 member.pane.resizeRetry = member.pane.owner.Load() != nil
206207 member.retry = member.pane.resizeRetry
207- member.pane.mu.Unlock()
208+ }
209+ member.pane.mu.Unlock()
210+ if !current {
208211 return false
209212 }
213+ }
214+ for i := range plan.members {
215+ member := &plan.members[i]
216+ member.pane.mu.Lock()
210217 member.pane.rect = member.rect
211218 if member.ok {
212219 member.pane.resizeRetry = false
@@ -424,13 +431,13 @@ func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, cur
424431 // relaunched, or resized slot must never receive this attempt's geometry.
425432 tb.mu.Lock()
426433 currentSlot := tb.floating.state == floatingVisible && tb.floating.generation == generation && tb.floating.pane == p && tb.size == size &&
427- resizeMemberOwnerCurrentLocked(&plan.members[0])
434+ resizeMemberOwnerCurrent(&plan.members[0])
428435 if current != nil && !current() {
429436 currentSlot = false
430437 }
431438 if currentSlot {
432439 p.mu.Lock()
433- currentSlot = resizeMemberOwnerCurrentLocked(&plan.members[0])
440+ currentSlot = resizeMemberOwnerCurrent(&plan.members[0])
434441 if currentSlot {
435442 p.rect = geometry.Inner
436443 p.popupGeometry = geometry
@@ -443,7 +450,7 @@ func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, cur
443450 }
444451 tb.mu.Unlock()
445452 if !currentSlot {
446- ownerCurrent := resizeMemberOwnerCurrentLocked(&plan.members[0])
453+ ownerCurrent := resizeMemberOwnerCurrent(&plan.members[0])
447454 // This attempt has no tiled transaction loop to retain the gate for, so
448455 // discard its buffered bytes at the old screen size before returning.
449456 for i := range plan.members {
@@ -605,11 +612,12 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad
605612 members = append(members, floatingResult.members...)
606613 failed = append(failed, floatingResult.failed...)
607614 }
608- if len(failed) != 0 && resizeMembersOwnerCurrent(failed) {
615+ failedCurrent := len(failed) == 0 || resizeMembersOwnerCurrent(failed)
616+ if len(failed) != 0 && failedCurrent {
609617 d.notify(sess, domain.NoticeWarn, domain.NoticeResizeFailed,
610618 "pane resize failed; retrying in background", failed[len(failed)-1].err)
611619 }
612- if len(failed) != 0 && !resizeMembersOwnerCurrent(failed) {
620+ if len(failed) != 0 && !failedCurrent {
613621 return resizeApplyResult{}, false
614622 }
615623 return resizeApplyResult{members: members, failed: failed}, true
@@ -88,7 +88,7 @@ func (d *Daemon) applyPreparedTabMembers(plan *preparedTabLayout) {
88 p := member.pane88 p := member.pane
89 p.resizeMu.Lock()89 p.resizeMu.Lock()
90 p.mu.Lock()90 p.mu.Lock()
91- if !resizeMemberOwnerCurrentLocked(member) {91+ if !resizeMemberOwnerCurrent(member) {
92 member.retry = p.owner.Load() != nil92 member.retry = p.owner.Load() != nil
93 p.resizeRetry = member.retry93 p.resizeRetry = member.retry
94 p.mu.Unlock()94 p.mu.Unlock()
@@ -111,7 +111,7 @@ func (d *Daemon) applyPreparedTabMembers(plan *preparedTabLayout) {
111 } else if err := pty.Resize(rectSize(member.rect)); err != nil {111 } else if err := pty.Resize(rectSize(member.rect)); err != nil {
112 d.log.Warn("pty resize failed", "err", err)112 d.log.Warn("pty resize failed", "err", err)
113 p.mu.Lock()113 p.mu.Lock()
114- current := resizeMemberOwnerCurrentLocked(member)114+ current := resizeMemberOwnerCurrent(member)
115 p.resizeRetry = true115 p.resizeRetry = true
116 p.mu.Unlock()116 p.mu.Unlock()
117 d.replayResizePending(member.session, member.tab, p, false, member.rect)117 d.replayResizePending(member.session, member.tab, p, false, member.rect)
@@ -121,7 +121,7 @@ func (d *Daemon) applyPreparedTabMembers(plan *preparedTabLayout) {
121 }121 }
122 } else {122 } else {
123 p.mu.Lock()123 p.mu.Lock()
124- current := resizeMemberOwnerCurrentLocked(member)124+ current := resizeMemberOwnerCurrent(member)
125 if !current && p.owner.Load() != nil {125 if !current && p.owner.Load() != nil {
126 p.resizeRetry = true126 p.resizeRetry = true
127 member.retry = true127 member.retry = true
@@ -134,7 +134,7 @@ func (d *Daemon) applyPreparedTabMembers(plan *preparedTabLayout) {
134 }134 }
135 }135 }
136 136
137-func resizeMemberOwnerCurrentLocked(member *resizeMember) bool {137+func resizeMemberOwnerCurrent(member *resizeMember) bool {
138 if member == nil || member.pane == nil {138 if member == nil || member.pane == nil {
139 return false139 return false
140 }140 }
@@ -154,7 +154,7 @@ func validatePreparedTabLayoutLocked(tb *tab, plan *preparedTabLayout) bool {
154 }154 }
155 for i := range plan.members {155 for i := range plan.members {
156 member := &plan.members[i]156 member := &plan.members[i]
157- if tb.panes[member.pane.id] != member.pane || !resizeMemberOwnerCurrentLocked(member) {157+ if tb.panes[member.pane.id] != member.pane || !resizeMemberOwnerCurrent(member) {
158 return false158 return false
159 }159 }
160 }160 }
@@ -163,7 +163,7 @@ func validatePreparedTabLayoutLocked(tb *tab, plan *preparedTabLayout) bool {
163 163
164 func resizeMembersOwnerCurrent(members []resizeMember) bool {164 func resizeMembersOwnerCurrent(members []resizeMember) bool {
165 for i := range members {165 for i := range members {
166- if !resizeMemberOwnerCurrentLocked(&members[i]) {166+ if !resizeMemberOwnerCurrent(&members[i]) {
167 return false167 return false
168 }168 }
169 }169 }
@@ -201,12 +201,19 @@ func commitPreparedTabLayoutLocked(plan *preparedTabLayout) bool {
201 for i := range plan.members {201 for i := range plan.members {
202 member := &plan.members[i]202 member := &plan.members[i]
203 member.pane.mu.Lock()203 member.pane.mu.Lock()
204- if !resizeMemberOwnerCurrentLocked(member) {204+ current := resizeMemberOwnerCurrent(member)
205+ if !current {
205 member.pane.resizeRetry = member.pane.owner.Load() != nil206 member.pane.resizeRetry = member.pane.owner.Load() != nil
206 member.retry = member.pane.resizeRetry207 member.retry = member.pane.resizeRetry
207- member.pane.mu.Unlock()208+ }
209+ member.pane.mu.Unlock()
210+ if !current {
208 return false211 return false
209 }212 }
213+ }
214+ for i := range plan.members {
215+ member := &plan.members[i]
216+ member.pane.mu.Lock()
210 member.pane.rect = member.rect217 member.pane.rect = member.rect
211 if member.ok {218 if member.ok {
212 member.pane.resizeRetry = false219 member.pane.resizeRetry = false
@@ -424,13 +431,13 @@ func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, cur
424 // relaunched, or resized slot must never receive this attempt's geometry.431 // relaunched, or resized slot must never receive this attempt's geometry.
425 tb.mu.Lock()432 tb.mu.Lock()
426 currentSlot := tb.floating.state == floatingVisible && tb.floating.generation == generation && tb.floating.pane == p && tb.size == size &&433 currentSlot := tb.floating.state == floatingVisible && tb.floating.generation == generation && tb.floating.pane == p && tb.size == size &&
427- resizeMemberOwnerCurrentLocked(&plan.members[0])434+ resizeMemberOwnerCurrent(&plan.members[0])
428 if current != nil && !current() {435 if current != nil && !current() {
429 currentSlot = false436 currentSlot = false
430 }437 }
431 if currentSlot {438 if currentSlot {
432 p.mu.Lock()439 p.mu.Lock()
433- currentSlot = resizeMemberOwnerCurrentLocked(&plan.members[0])440+ currentSlot = resizeMemberOwnerCurrent(&plan.members[0])
434 if currentSlot {441 if currentSlot {
435 p.rect = geometry.Inner442 p.rect = geometry.Inner
436 p.popupGeometry = geometry443 p.popupGeometry = geometry
@@ -443,7 +450,7 @@ func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, cur
443 }450 }
444 tb.mu.Unlock()451 tb.mu.Unlock()
445 if !currentSlot {452 if !currentSlot {
446- ownerCurrent := resizeMemberOwnerCurrentLocked(&plan.members[0])453+ ownerCurrent := resizeMemberOwnerCurrent(&plan.members[0])
447 // This attempt has no tiled transaction loop to retain the gate for, so454 // This attempt has no tiled transaction loop to retain the gate for, so
448 // discard its buffered bytes at the old screen size before returning.455 // discard its buffered bytes at the old screen size before returning.
449 for i := range plan.members {456 for i := range plan.members {
@@ -605,11 +612,12 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad
605 members = append(members, floatingResult.members...)612 members = append(members, floatingResult.members...)
606 failed = append(failed, floatingResult.failed...)613 failed = append(failed, floatingResult.failed...)
607 }614 }
608- if len(failed) != 0 && resizeMembersOwnerCurrent(failed) {615+ failedCurrent := len(failed) == 0 || resizeMembersOwnerCurrent(failed)
616+ if len(failed) != 0 && failedCurrent {
609 d.notify(sess, domain.NoticeWarn, domain.NoticeResizeFailed,617 d.notify(sess, domain.NoticeWarn, domain.NoticeResizeFailed,
610 "pane resize failed; retrying in background", failed[len(failed)-1].err)618 "pane resize failed; retrying in background", failed[len(failed)-1].err)
611 }619 }
612- if len(failed) != 0 && !resizeMembersOwnerCurrent(failed) {620+ if len(failed) != 0 && !failedCurrent {
613 return resizeApplyResult{}, false621 return resizeApplyResult{}, false
614 }622 }
615 return resizeApplyResult{members: members, failed: failed}, true623 return resizeApplyResult{members: members, failed: failed}, true
M internal/usecase/daemon/transactional_resize_test.go +1 -1
@@ -188,7 +188,7 @@ func TestProcessPTYDataRetainsCallbacksDuringResizeReplay(t *testing.T) {
188188 // DSR is flushed back to the child and OSC 52 is forwarded through the
189189 // normal asynchronous client path, proving replay uses processPTYData.
190190 var clipboardOutput string
191- for range 3 {
191+ for {
192192 frame := awaitFrame(t, sends, ports.MsgOutput)
193193 out, err := ports.UnmarshalOutput(frame.Payload)
194194 require.NoError(t, err)
@@ -188,7 +188,7 @@ func TestProcessPTYDataRetainsCallbacksDuringResizeReplay(t *testing.T) {
188 // DSR is flushed back to the child and OSC 52 is forwarded through the188 // DSR is flushed back to the child and OSC 52 is forwarded through the
189 // normal asynchronous client path, proving replay uses processPTYData.189 // normal asynchronous client path, proving replay uses processPTYData.
190 var clipboardOutput string190 var clipboardOutput string
191- for range 3 {191+ for {
192 frame := awaitFrame(t, sends, ports.MsgOutput)192 frame := awaitFrame(t, sends, ports.MsgOutput)
193 out, err := ports.UnmarshalOutput(frame.Payload)193 out, err := ports.UnmarshalOutput(frame.Payload)
194 require.NoError(t, err)194 require.NoError(t, err)
M internal/usecase/keys/router.go +24 -7
@@ -89,14 +89,20 @@ func (r *Router) RouteWithHandler(data []byte, h Handler) {
8989 h = r.h
9090 }
9191 r.mu.Lock()
92- defer r.mu.Unlock()
92+ var pendingAlt []byte
9393 if r.pending {
94- pendingAlt := r.pendingAlt
95- pendingHandler := r.pendingHandler
94+ pendingAlt = append([]byte(nil), r.pendingAlt...)
95+ }
96+ pendingHandler := r.pendingHandler
97+ wasPending := r.pending
98+ if wasPending {
9699 r.stopTimer()
97100 r.pending = false
98101 r.pendingAlt = nil
99102 r.pendingHandler = nil
103+ }
104+ r.mu.Unlock()
105+ if wasPending {
100106 combined := append(append([]byte(nil), pendingAlt...), data...)
101107 if consumed := r.routeAfterPendingESC(combined, len(pendingAlt), pendingHandler); consumed > len(pendingAlt) {
102108 data = data[consumed-len(pendingAlt):]
@@ -209,6 +215,7 @@ func (r *Router) routeAfterPendingESC(data []byte, pendingAltLen int, h Handler)
209215 }
210216
211217 func (r *Router) retainESC(h Handler, altBytes ...[]byte) {
218+ r.mu.Lock()
212219 r.pending = true
213220 r.pendingAlt = nil
214221 r.pendingHandler = h
@@ -217,23 +224,33 @@ func (r *Router) retainESC(h Handler, altBytes ...[]byte) {
217224 }
218225 r.timer = r.clock.NewTimer(r.delay)
219226 r.pendingDone = make(chan struct{})
227+ timer, done := r.timer, r.pendingDone
228+ r.mu.Unlock()
220229 go func(timer ports.Timer, done <-chan struct{}) {
221230 select {
222231 case <-timer.C():
223232 case <-done:
224233 return
225234 }
235+ var (
236+ data []byte
237+ h Handler
238+ )
226239 r.mu.Lock()
227- defer r.mu.Unlock()
228240 if r.pending && r.timer == timer {
229241 r.pending = false
230- data := append([]byte{ESC}, r.pendingAlt...)
231- h := r.pendingHandler
242+ data = append([]byte{ESC}, r.pendingAlt...)
243+ h = r.pendingHandler
232244 r.pendingAlt = nil
233245 r.pendingHandler = nil
246+ r.timer = nil
247+ r.pendingDone = nil
248+ }
249+ r.mu.Unlock()
250+ if h != nil {
234251 r.forward(data, h)
235252 }
236- }(r.timer, r.pendingDone)
253+ }(timer, done)
237254 }
238255
239256 func (r *Router) stopTimer() {
@@ -89,14 +89,20 @@ func (r *Router) RouteWithHandler(data []byte, h Handler) {
89 h = r.h89 h = r.h
90 }90 }
91 r.mu.Lock()91 r.mu.Lock()
92- defer r.mu.Unlock()92+ var pendingAlt []byte
93 if r.pending {93 if r.pending {
94- pendingAlt := r.pendingAlt94+ pendingAlt = append([]byte(nil), r.pendingAlt...)
95- pendingHandler := r.pendingHandler95+ }
96+ pendingHandler := r.pendingHandler
97+ wasPending := r.pending
98+ if wasPending {
96 r.stopTimer()99 r.stopTimer()
97 r.pending = false100 r.pending = false
98 r.pendingAlt = nil101 r.pendingAlt = nil
99 r.pendingHandler = nil102 r.pendingHandler = nil
103+ }
104+ r.mu.Unlock()
105+ if wasPending {
100 combined := append(append([]byte(nil), pendingAlt...), data...)106 combined := append(append([]byte(nil), pendingAlt...), data...)
101 if consumed := r.routeAfterPendingESC(combined, len(pendingAlt), pendingHandler); consumed > len(pendingAlt) {107 if consumed := r.routeAfterPendingESC(combined, len(pendingAlt), pendingHandler); consumed > len(pendingAlt) {
102 data = data[consumed-len(pendingAlt):]108 data = data[consumed-len(pendingAlt):]
@@ -209,6 +215,7 @@ func (r *Router) routeAfterPendingESC(data []byte, pendingAltLen int, h Handler)
209 }215 }
210 216
211 func (r *Router) retainESC(h Handler, altBytes ...[]byte) {217 func (r *Router) retainESC(h Handler, altBytes ...[]byte) {
218+ r.mu.Lock()
212 r.pending = true219 r.pending = true
213 r.pendingAlt = nil220 r.pendingAlt = nil
214 r.pendingHandler = h221 r.pendingHandler = h
@@ -217,23 +224,33 @@ func (r *Router) retainESC(h Handler, altBytes ...[]byte) {
217 }224 }
218 r.timer = r.clock.NewTimer(r.delay)225 r.timer = r.clock.NewTimer(r.delay)
219 r.pendingDone = make(chan struct{})226 r.pendingDone = make(chan struct{})
227+ timer, done := r.timer, r.pendingDone
228+ r.mu.Unlock()
220 go func(timer ports.Timer, done <-chan struct{}) {229 go func(timer ports.Timer, done <-chan struct{}) {
221 select {230 select {
222 case <-timer.C():231 case <-timer.C():
223 case <-done:232 case <-done:
224 return233 return
225 }234 }
235+ var (
236+ data []byte
237+ h Handler
238+ )
226 r.mu.Lock()239 r.mu.Lock()
227- defer r.mu.Unlock()
228 if r.pending && r.timer == timer {240 if r.pending && r.timer == timer {
229 r.pending = false241 r.pending = false
230- data := append([]byte{ESC}, r.pendingAlt...)242+ data = append([]byte{ESC}, r.pendingAlt...)
231- h := r.pendingHandler243+ h = r.pendingHandler
232 r.pendingAlt = nil244 r.pendingAlt = nil
233 r.pendingHandler = nil245 r.pendingHandler = nil
246+ r.timer = nil
247+ r.pendingDone = nil
248+ }
249+ r.mu.Unlock()
250+ if h != nil {
234 r.forward(data, h)251 r.forward(data, h)
235 }252 }
236- }(r.timer, r.pendingDone)253+ }(timer, done)
237 }254 }
238 255
239 func (r *Router) stopTimer() {256 func (r *Router) stopTimer() {

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