fix(review): address concurrency and lifecycle findings
- commit
- afa2cd49b1e4d6977d4b2442295662ae419a4daa
- author
- brice · · 2026-07-28 15:51 UTC
- parent
- dad29ba
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
55 files · +698 -501
M internal/app/cmd_test.go +2 -1
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "io" | |
| 7 | 7 | "math" | |
| 8 | 8 | "reflect" | |
| 9 | + | "strconv" | |
| 9 | 10 | "strings" | |
| 10 | 11 | "testing" | |
| 11 | 12 | ||
| @@ -156,7 +157,7 @@ func TestMoveCmdInvalidArgumentResultExitsTwo(t *testing.T) { | |||
| 156 | 157 | {slug: "move-tab"}, | |
| 157 | 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 | 161 | transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{ | |
| 161 | 162 | Code: ports.ErrInvalidCommandArgs, Text: "invalid command arguments", | |
| 162 | 163 | })}} | |
| @@ -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 | |||
| 123 | 123 | drainDeadline = newRoleEffectDrainDeadline(d.clock) | |
| 124 | 124 | 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 | 127 | if d.afterRoleEffectGateFrozen != nil { | |
| 128 | 128 | d.afterRoleEffectGateFrozen(req.action, ac) | |
| 129 | 129 | } | |
| 130 | - | }, participants.clients...) | |
| 130 | + | }}, participants.clients...) | |
| 131 | 131 | if drainDeadline != nil { | |
| 132 | 132 | drainDeadline.stop() | |
| 133 | 133 | } | |
| @@ -123,11 +123,11 @@ func (d *Daemon) freezeAttachmentTransition(req attachmentTransitionRequest, par | |||||
| 123 | drainDeadline = newRoleEffectDrainDeadline(d.clock) | 123 | drainDeadline = newRoleEffectDrainDeadline(d.clock) | ||
| 124 | drainDone = drainDeadline.Done | 124 | 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 | |||
| 105 | 105 | case ports.MsgPing: | |
| 106 | 106 | if err := token.sendActiveControl(framePong()); err != nil { | |
| 107 | 107 | token.endRoleEffect() | |
| 108 | - | d.detachOnSendError(token.sess, token.ac, token.transport.transport) | |
| 108 | + | d.detachOnRoleSendError(token, token.transport.transport) | |
| 109 | 109 | return true | |
| 110 | 110 | } | |
| 111 | 111 | 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 true | 109 | 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 | |||
| 255 | 255 | return token.transport.transport, errAttachmentTransition | |
| 256 | 256 | } | |
| 257 | 257 | expected := token.transport | |
| 258 | - | send := func(owned bool) error { | |
| 258 | + | ownedTransport, owned := expected.transport.(ports.OwnedSynchronousTransport) | |
| 259 | + | send := func() error { | |
| 259 | 260 | ac := token.ac | |
| 260 | 261 | ac.sendMu.Lock() | |
| 261 | 262 | defer ac.sendMu.Unlock() | |
| @@ -268,7 +269,7 @@ func (d *Daemon) boundedSendClipboardForward(item clipboardForward, ticket *role | |||
| 268 | 269 | frame := ac.output.sideEffect(item.seq, ac.echoAck.Load()) | |
| 269 | 270 | var err error | |
| 270 | 271 | if owned { | |
| 271 | - | err = expected.transport.(ports.OwnedSynchronousTransport).SendSynchronous(frame) | |
| 272 | + | err = ownedTransport.SendSynchronous(frame) | |
| 272 | 273 | } else { | |
| 273 | 274 | err = expected.transport.Send(frame) | |
| 274 | 275 | } | |
| @@ -278,10 +279,10 @@ func (d *Daemon) boundedSendClipboardForward(item clipboardForward, ticket *role | |||
| 278 | 279 | ticket.endTransportSend() | |
| 279 | 280 | return err | |
| 280 | 281 | } | |
| 281 | - | if _, owned := expected.transport.(ports.OwnedSynchronousTransport); owned { | |
| 282 | - | return expected.transport, send(true) | |
| 282 | + | if owned { | |
| 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 | 288 | // 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, errAttachmentTransition | 255 | return token.transport.transport, errAttachmentTransition | ||
| 256 | } | 256 | } | ||
| 257 | expected := token.transport | 257 | expected := token.transport | ||
| 258 | - | send := func(owned bool) error { | 258 | + | ownedTransport, owned := expected.transport.(ports.OwnedSynchronousTransport) |
| 259 | + | send := func() error { | |||
| 259 | ac := token.ac | 260 | 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 error | 270 | 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 err | 280 | 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 sendMu | 288 | // 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 | |||
| 209 | 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 | 214 | tb := sess.activeTab() | |
| 214 | 215 | tb.mu.Lock() | |
| 215 | 216 | p := tb.terminalTargetLocked() | |
| 216 | 217 | tb.mu.Unlock() | |
| 218 | + | if p == nil { | |
| 219 | + | t.Fatal("clipboard owner has no terminal target") | |
| 220 | + | } | |
| 217 | 221 | return p.effectLease() | |
| 218 | 222 | } | |
| 219 | 223 | ||
| @@ -348,7 +352,7 @@ func TestQueuedClipboardAfterPaneMoveDoesNotSendToFormerOwner(t *testing.T) { | |||
| 348 | 352 | sess.mu.Lock() | |
| 349 | 353 | sess.clipboardWorkerRunning = true | |
| 350 | 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 | 357 | sourceTab := sess.activeTab() | |
| 354 | 358 | p := sourceTab.focusedPane() | |
| @@ -363,10 +367,11 @@ func TestQueuedClipboardAfterPaneMoveDoesNotSendToFormerOwner(t *testing.T) { | |||
| 363 | 367 | } | |
| 364 | 368 | ||
| 365 | 369 | 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 | |
| 370 | 375 | } | |
| 371 | 376 | ||
| 372 | 377 | func newMovingClipboardErrorTransport() *movingClipboardErrorTransport { | |
| @@ -378,7 +383,7 @@ func newMovingClipboardErrorTransport() *movingClipboardErrorTransport { | |||
| 378 | 383 | } | |
| 379 | 384 | ||
| 380 | 385 | func (t *movingClipboardErrorTransport) Send(ports.Frame) error { | |
| 381 | - | close(t.started) | |
| 386 | + | t.startedOnce.Do(func() { close(t.started) }) | |
| 382 | 387 | <-t.release | |
| 383 | 388 | return errors.New("clipboard send failed after pane move") | |
| 384 | 389 | } | |
| @@ -399,7 +404,7 @@ func TestQueuedClipboardRevalidatesOwnerAfterWaitingForClientSendLock(t *testing | |||
| 399 | 404 | sess.mu.Lock() | |
| 400 | 405 | sess.clipboardWorkerRunning = true | |
| 401 | 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 | 409 | ac.sendMu.Lock() | |
| 405 | 410 | sendMuLocked := true | |
| @@ -414,9 +419,7 @@ func TestQueuedClipboardRevalidatesOwnerAfterWaitingForClientSendLock(t *testing | |||
| 414 | 419 | close(workerDone) | |
| 415 | 420 | }() | |
| 416 | 421 | 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 | |
| 420 | 423 | }, time.Second, time.Millisecond, "clipboard send was not admitted before waiting on sendMu") | |
| 421 | 424 | ||
| 422 | 425 | sourceTab := sess.activeTab() | |
| @@ -441,7 +444,7 @@ func TestClipboardSendErrorAfterPaneMoveDoesNotDetachFormerOwner(t *testing.T) { | |||
| 441 | 444 | sess.mu.Lock() | |
| 442 | 445 | sess.clipboardWorkerRunning = true | |
| 443 | 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 | 449 | workerDone := make(chan struct{}) | |
| 447 | 450 | go func() { | |
| @@ -478,7 +481,7 @@ func TestQueuedClipboardBeforeSnatchDropsExactStaleCapability(t *testing.T) { | |||
| 478 | 481 | sess.mu.Lock() | |
| 479 | 482 | sess.clipboardWorkerRunning = true | |
| 480 | 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 | 486 | newTransport := &closeTrackingTransport{} | |
| 484 | 487 | next := &attachedClient{tr: newTransport, output: newOutputStateStream(), size: old.size} | |
| @@ -501,7 +504,7 @@ func TestForwardClipboardAsyncSerializesClipboardWrites(t *testing.T) { | |||
| 501 | 504 | tr := newBlockingClipboardTransport() | |
| 502 | 505 | ac.replaceTransport(tr) | |
| 503 | 506 | publishActiveClipboardCapability(d, sess, ac, tr) | |
| 504 | - | owner := clipboardOwnerLease(sess) | |
| 507 | + | owner := clipboardOwnerLease(t, sess) | |
| 505 | 508 | ||
| 506 | 509 | first := base64.StdEncoding.EncodeToString([]byte("first")) | |
| 507 | 510 | 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 = true | 353 | 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.Once | 373 | + | 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.release | 387 | <-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 = true | 405 | 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 := true | 410 | 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 = true | 445 | 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 = true | 482 | 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 | |||
| 53 | 53 | return commandFailure(code, text) | |
| 54 | 54 | } | |
| 55 | 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 | 61 | tb, pane, code, text := resolveControlTarget(sess, cmd.Target, request) | |
| 57 | 62 | if code != 0 { | |
| 58 | 63 | 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) { | |||
| 958 | 958 | right.active = 1 | |
| 959 | 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 | 969 | start := make(chan struct{}) | |
| 962 | - | done := make(chan ports.CommandResult, 2) | |
| 970 | + | done := make(chan struct{}, 2) | |
| 963 | 971 | go func() { | |
| 964 | 972 | <-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{}{} | |
| 968 | 975 | }() | |
| 969 | 976 | go func() { | |
| 970 | 977 | <-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{}{} | |
| 974 | 980 | }() | |
| 975 | 981 | close(start) | |
| 976 | - | for range 2 { | |
| 982 | + | for _, sends := range []chan ports.Frame{leftSends, rightSends} { | |
| 977 | 983 | select { | |
| 978 | - | case result := <-done: | |
| 984 | + | case <-done: | |
| 985 | + | result := awaitCommandResult(t, sends) | |
| 979 | 986 | require.True(t, result.OK, result.Text) | |
| 980 | 987 | case <-time.After(5 * time.Second): | |
| 981 | 988 | t.Fatal("opposite-direction move commands deadlocked") | |
| @@ -958,24 +958,31 @@ func TestHandleCommandOppositeMoveCommandsDoNotDeadlock(t *testing.T) { | |||||
| 958 | right.active = 1 | 958 | 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 | <-start | 972 | <-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 | <-start | 977 | <-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) { | |||
| 55 | 55 | ac.overlays.copyMu.Unlock() | |
| 56 | 56 | } | |
| 57 | 57 | ||
| 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 | + | } | |
| 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 | 91 | 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 { | |||
| 160 | 160 | // attachment snapshot respectively. beforeMovePaneCommit runs inside the | |
| 161 | 161 | // non-failing publication section. None is set in production, and none may | |
| 162 | 162 | // 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() | |
| 168 | 169 | // afterDetachRoleEffectsFrozen observes terminal detach after it wins the | |
| 169 | 170 | // attachment gate but before it checks session ownership. | |
| 170 | 171 | afterDetachRoleEffectsFrozen func() | |
| @@ -1070,9 +1071,11 @@ type protoErr struct { | |||
| 1070 | 1071 | ||
| 1071 | 1072 | func (e *protoErr) Error() string { return e.text } | |
| 1072 | 1073 | ||
| 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. | |
| 1076 | 1079 | func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size, term terminalEnv, h ports.Hello) (*attachedClient, error) { | |
| 1077 | 1080 | // Session state is the sole source for future PTY children. Update it before | |
| 1078 | 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 | 1169 | // expired, or raced with lifecycle teardown, fail closed instead of routing | |
| 1167 | 1170 | // the Hello as an ordinary attach that could create or replace ownership. | |
| 1168 | 1171 | 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 | |
| 1171 | 1190 | } | |
| 1172 | - | return nil, nil, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"} | |
| 1173 | 1191 | } | |
| 1174 | 1192 | if h.Intent == ports.IntentResume || h.Intent == ports.IntentAttach { | |
| 1175 | 1193 | ctx := d.serveCtx | |
| @@ -160,11 +160,12 @@ type Daemon struct { | |||||
| 160 | // attachment snapshot respectively. beforeMovePaneCommit runs inside the | 160 | // attachment snapshot respectively. beforeMovePaneCommit runs inside the | ||
| 161 | // non-failing publication section. None is set in production, and none may | 161 | // 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 the | 169 | // 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. It | 1074 | + | // finishAttach completes an attachment prepared while d.mu is held. Every |
| 1074 | - | // publishes terminal and role state before releasing d.mu, then defers | 1075 | + | // 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 before | 1080 | // 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 routing | 1169 | // 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, err | 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 | |||
| 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.serveCtx | 1193 | ctx := d.serveCtx | ||
M internal/usecase/daemon/floating.go +3 -0
| @@ -186,6 +186,9 @@ func (d *Daemon) activateTabAfterResizeForLease(sess *session, tb *tab, outerRes | |||
| 186 | 186 | ac = sess.client | |
| 187 | 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 | 192 | if lease != nil { | |
| 190 | 193 | rc := sess.renderCoordinator() | |
| 191 | 194 | if rc == nil || !rc.leaseCurrent(lease, true) { | |
| @@ -186,6 +186,9 @@ func (d *Daemon) activateTabAfterResizeForLease(sess *session, tb *tab, outerRes | |||||
| 186 | ac = sess.client | 186 | 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 | |||
| 298 | 298 | g1 := tb.beginFloatingWarmLocked(true) | |
| 299 | 299 | publishPaneOwner(first, sess, tb, g1) | |
| 300 | 300 | 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) | |
| 303 | 303 | generation := tb.beginFloatingWarmLocked(true) | |
| 304 | 304 | require.True(t, tb.installFloatingLocked(second, generation)) | |
| 305 | 305 | 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 | |||
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | 5 | "bytes" | |
| 6 | + | "errors" | |
| 6 | 7 | "strconv" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/bnema/vev/internal/domain" | |
| @@ -388,19 +389,19 @@ func (h daemonKeyHandler) Action(action keys.Action) { | |||
| 388 | 389 | h.d.reportError(sess, err) | |
| 389 | 390 | } | |
| 390 | 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 | 393 | h.d.reportError(sess, err) | |
| 393 | 394 | } | |
| 394 | 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 | 397 | h.d.reportError(sess, err) | |
| 397 | 398 | } | |
| 398 | 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 | 401 | h.d.reportError(sess, err) | |
| 401 | 402 | } | |
| 402 | 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 | 405 | h.d.reportError(sess, err) | |
| 405 | 406 | } | |
| 406 | 407 | 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 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "reflect" | |
| 5 | + | ||
| 4 | 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 | 129 | source.mu.Unlock() | |
| 128 | 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 | 132 | if d.afterRoleEffectGateFrozen != nil { | |
| 131 | 133 | d.afterRoleEffectGateFrozen("move-pane", ac) | |
| 132 | 134 | } | |
| 133 | - | }, handoffParticipants.clients...) | |
| 135 | + | }}, handoffParticipants.clients...) | |
| 134 | 136 | if !frozen.acquired || !frozen.drained { | |
| 135 | 137 | frozen.unfreeze() | |
| 136 | 138 | return errMovePaneInvalid | |
| @@ -152,7 +154,7 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) { | |||
| 152 | 154 | } | |
| 153 | 155 | source.mu.Unlock() | |
| 154 | 156 | d.mu.Unlock() | |
| 155 | - | frozen = tryFreezeRoleEffectGatesInterruptingObserved(interrupts, nil, participants...) | |
| 157 | + | frozen = freezeRoleEffectGatesWith(roleEffectFreezeOptions{interrupts: interrupts, nonblocking: true}, participants...) | |
| 156 | 158 | if !frozen.acquired || !frozen.drained { | |
| 157 | 159 | frozen.unfreeze() | |
| 158 | 160 | return errMovePaneInvalid | |
| @@ -278,12 +280,24 @@ func moveTabMemberLocked(sess *session, target *tab) bool { | |||
| 278 | 280 | } | |
| 279 | 281 | ||
| 280 | 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 | 294 | if a == b { | |
| 282 | 295 | a.mu.Lock() | |
| 283 | 296 | return a.mu.Unlock | |
| 284 | 297 | } | |
| 285 | 298 | 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 | 301 | first, second = second, first | |
| 288 | 302 | } | |
| 289 | 303 | first.mu.Lock() | |
| @@ -1,6 +1,8 @@ | |||||
| 1 | package daemon | 1 | 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 errMovePaneInvalid | 138 | 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 errMovePaneInvalid | 160 | 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.Unlock | 296 | return a.mu.Unlock | ||
| 284 | } | 297 | } | ||
| 285 | first, second := a, b | 298 | 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, first | 301 | 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 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "errors" | |
| 5 | 4 | "sync" | |
| 6 | 5 | "testing" | |
| 7 | 6 | ||
| @@ -11,8 +10,6 @@ import ( | |||
| 11 | 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 | 13 | func catalogueUpdateNames(updates []domain.CatalogueMetadataUpdate) []string { | |
| 17 | 14 | names := make([]string, 0, len(updates)) | |
| 18 | 15 | for _, update := range updates { | |
| @@ -22,11 +19,12 @@ func catalogueUpdateNames(updates []domain.CatalogueMetadataUpdate) []string { | |||
| 22 | 19 | } | |
| 23 | 20 | ||
| 24 | 21 | 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 | |
| 30 | 28 | } | |
| 31 | 29 | ||
| 32 | 30 | func (c *moveCatalogueRecorder) Records() ([]domain.CatalogueRecord, error) { return nil, nil } | |
| @@ -40,23 +38,25 @@ func (c *moveCatalogueRecorder) Delete(string) error { | |||
| 40 | 38 | func (c *moveCatalogueRecorder) Sync() error { return nil } | |
| 41 | 39 | func (c *moveCatalogueRecorder) Close() error { return nil } | |
| 42 | 40 | func (c *moveCatalogueRecorder) UpdateMetadata(update domain.CatalogueMetadataUpdate) error { | |
| 41 | + | violated := false | |
| 43 | 42 | if c.daemon.mu.TryLock() { | |
| 44 | 43 | c.daemon.mu.Unlock() | |
| 45 | 44 | } else { | |
| 46 | - | return errMoveCatalogueLocked | |
| 45 | + | violated = true | |
| 47 | 46 | } | |
| 48 | 47 | if c.source.mu.TryLock() { | |
| 49 | 48 | c.source.mu.Unlock() | |
| 50 | 49 | } else { | |
| 51 | - | return errMoveCatalogueLocked | |
| 50 | + | violated = true | |
| 52 | 51 | } | |
| 53 | 52 | if c.destination.mu.TryLock() { | |
| 54 | 53 | c.destination.mu.Unlock() | |
| 55 | 54 | } else { | |
| 56 | - | return errMoveCatalogueLocked | |
| 55 | + | violated = true | |
| 57 | 56 | } | |
| 58 | 57 | c.mu.Lock() | |
| 59 | 58 | c.updates = append(c.updates, update) | |
| 59 | + | c.lockViolation = c.lockViolation || violated | |
| 60 | 60 | c.mu.Unlock() | |
| 61 | 61 | return nil | |
| 62 | 62 | } | |
| @@ -169,7 +169,9 @@ func TestMovePaneAcceptanceRetainsSourceFocusAndDestinationActivity(t *testing.T | |||
| 169 | 169 | destination.snapshotMu.Unlock() | |
| 170 | 170 | catalogue.mu.Lock() | |
| 171 | 171 | updates := append([]domain.CatalogueMetadataUpdate(nil), catalogue.updates...) | |
| 172 | + | lockViolation := catalogue.lockViolation | |
| 172 | 173 | catalogue.mu.Unlock() | |
| 174 | + | require.False(t, lockViolation, "catalogue publication ran under an architecture lock") | |
| 173 | 175 | require.ElementsMatch(t, []string{source.name, destination.name}, catalogueUpdateNames(updates)) | |
| 174 | 176 | } | |
| 175 | 177 | ||
| @@ -1,7 +1,6 @@ | |||||
| 1 | package daemon | 1 | 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 *Daemon | 22 | + | daemon *Daemon |
| 26 | - | source *session | 23 | + | source *session |
| 27 | - | destination *session | 24 | + | destination *session |
| 28 | - | mu sync.Mutex | 25 | + | mu sync.Mutex |
| 29 | - | updates []domain.CatalogueMetadataUpdate | 26 | + | 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 errMoveCatalogueLocked | 45 | + | 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 errMoveCatalogueLocked | 50 | + | 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 errMoveCatalogueLocked | 55 | + | 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 nil | 61 | 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) { | |||
| 18 | 18 | defer releaseDestination() | |
| 19 | 19 | ||
| 20 | 20 | d, source, client, _ := newManualSessionWithPTYs(t, movedPTY, remainingPTY) | |
| 21 | + | require.NotNil(t, d.attachCoordinator(source, nil, client, true)) | |
| 21 | 22 | sourceTab := source.tabs[0] | |
| 22 | 23 | sourceTab.stableID = "source-tab" | |
| 23 | 24 | moved := sourceTab.focusedPane() | |
| @@ -40,6 +41,13 @@ func TestMovePaneReleasesResizeFencesBeforeAttachmentCleanup(t *testing.T) { | |||
| 40 | 41 | owner := moved.effectLeaseLocked() | |
| 41 | 42 | moved.mu.Unlock() | |
| 42 | 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 | 52 | sendLocked := make(chan struct{}) | |
| 45 | 53 | allowResizeFence := make(chan struct{}) | |
| @@ -50,7 +58,7 @@ func TestMovePaneReleasesResizeFencesBeforeAttachmentCleanup(t *testing.T) { | |||
| 50 | 58 | defer func() { d.afterResizeCommitSendLocked = nil }() | |
| 51 | 59 | resizeDone := make(chan bool, 1) | |
| 52 | 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 | 63 | awaitTestCompletion(t, sendLocked, "resize commit did not acquire sendMu") | |
| 56 | 64 | ||
| @@ -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 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "sync" | |
| 4 | 5 | "testing" | |
| 5 | 6 | "time" | |
| 6 | 7 | ||
| @@ -76,11 +77,12 @@ func TestMovePaneGateReservationRaceKillWinsWithoutDeadlock(t *testing.T) { | |||
| 76 | 77 | } | |
| 77 | 78 | killFrozen := make(chan struct{}) | |
| 78 | 79 | releaseKill := make(chan struct{}) | |
| 80 | + | var killFrozenOnce sync.Once | |
| 79 | 81 | d.afterRoleEffectGateFrozen = func(action string, _ *attachedClient) { | |
| 80 | 82 | if action != "" { | |
| 81 | 83 | return | |
| 82 | 84 | } | |
| 83 | - | close(killFrozen) | |
| 85 | + | killFrozenOnce.Do(func() { close(killFrozen) }) | |
| 84 | 86 | <-releaseKill | |
| 85 | 87 | } | |
| 86 | 88 | defer func() { | |
| @@ -119,17 +121,19 @@ func TestMovePaneGateReservationRaceMoveWinsWithoutDeadlock(t *testing.T) { | |||
| 119 | 121 | ||
| 120 | 122 | moveGateFrozen := make(chan struct{}) | |
| 121 | 123 | releaseMove := make(chan struct{}) | |
| 124 | + | var moveGateFrozenOnce sync.Once | |
| 122 | 125 | d.afterRoleEffectGateFrozen = func(action string, _ *attachedClient) { | |
| 123 | 126 | if action != "move-pane" { | |
| 124 | 127 | return | |
| 125 | 128 | } | |
| 126 | - | close(moveGateFrozen) | |
| 129 | + | moveGateFrozenOnce.Do(func() { close(moveGateFrozen) }) | |
| 127 | 130 | <-releaseMove | |
| 128 | 131 | } | |
| 129 | 132 | killSnapshotted := make(chan struct{}) | |
| 133 | + | var killSnapshottedOnce sync.Once | |
| 130 | 134 | d.afterRoleEffectParticipantsSnapshotted = func(action string, _ []*attachedClient) { | |
| 131 | 135 | if action == "" { | |
| 132 | - | close(killSnapshotted) | |
| 136 | + | killSnapshottedOnce.Do(func() { close(killSnapshotted) }) | |
| 133 | 137 | } | |
| 134 | 138 | } | |
| 135 | 139 | defer func() { | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package daemon | 1 | 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 | return | 83 | return | ||
| 82 | } | 84 | } | ||
| 83 | - | close(killFrozen) | 85 | + | killFrozenOnce.Do(func() { close(killFrozen) }) |
| 84 | <-releaseKill | 86 | <-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 | return | 127 | return | ||
| 125 | } | 128 | } | ||
| 126 | - | close(moveGateFrozen) | 129 | + | moveGateFrozenOnce.Do(func() { close(moveGateFrozen) }) |
| 127 | <-releaseMove | 130 | <-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 * | |||
| 294 | 294 | } | |
| 295 | 295 | ||
| 296 | 296 | func TestMoveTabFinalSourceHandoffValidationFailureIsAtomic(t *testing.T) { | |
| 297 | - | d, source, follower, destination, _, moved := setupMoveTabFinalSourceHandoff(t) | |
| 297 | + | d, source, follower, destination, _, _, moved := setupMoveTabFinalSourceHandoff(t) | |
| 298 | 298 | floating := newPaneWithStableID("floating", "floating-stable", newQuietPTY(), domain.Size{Cols: 20, Rows: 8}) | |
| 299 | 299 | floating.ctx, floating.cancel = context.WithCancel(d.paneProcessCtx) | |
| 300 | 300 | 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 | |||
| 41 | 41 | d.moveLifecycleActive++ | |
| 42 | 42 | d.signalMoveLifecycleChangedLocked() | |
| 43 | 43 | d.moveLifecycleMu.Unlock() | |
| 44 | + | if d.afterMoveLifecycleGateBeforeTeardownLocks != nil { | |
| 45 | + | d.afterMoveLifecycleGateBeforeTeardownLocks() | |
| 46 | + | } | |
| 44 | 47 | ||
| 45 | 48 | sessions := uniqueMoveSessions(source, destination) | |
| 46 | 49 | 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) { | |||
| 94 | 94 | // Both opposite-direction reservations must block on a before either takes | |
| 95 | 95 | // b. This proves order from immutable IDs rather than argument direction. | |
| 96 | 96 | first.teardownMu.Lock() | |
| 97 | + | beforeTeardownLocks := make(chan struct{}, 2) | |
| 98 | + | d.afterMoveLifecycleGateBeforeTeardownLocks = func() { beforeTeardownLocks <- struct{}{} } | |
| 99 | + | defer func() { d.afterMoveLifecycleGateBeforeTeardownLocks = nil }() | |
| 97 | 100 | results := make(chan *moveLifecycleReservation, 2) | |
| 98 | 101 | errs := make(chan error, 2) | |
| 99 | 102 | for _, pair := range [][2]*session{{first, second}, {second, first}} { | |
| @@ -104,6 +107,9 @@ func TestMoveLifecycleReservationsUseStableSessionOrder(t *testing.T) { | |||
| 104 | 107 | }() | |
| 105 | 108 | } | |
| 106 | 109 | awaitDaemonMoveActive(t, d, 2) | |
| 110 | + | for range 2 { | |
| 111 | + | awaitTestCompletion(t, beforeTeardownLocks, "move did not reach ordered teardown-lock admission") | |
| 112 | + | } | |
| 107 | 113 | require.True(t, second.teardownMu.TryLock(), "opposite move locked b before the shared lowest-ID session") | |
| 108 | 114 | second.teardownMu.Unlock() | |
| 109 | 115 | first.teardownMu.Unlock() | |
| @@ -94,6 +94,9 @@ func TestMoveLifecycleReservationsUseStableSessionOrder(t *testing.T) { | |||||
| 94 | // Both opposite-direction reservations must block on a before either takes | 94 | // 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 | |||
| 93 | 93 | id := layout.PaneID(fmt.Sprintf("pane-%d", next)) | |
| 94 | 94 | _, mapped := destination.panes[id] | |
| 95 | 95 | available := id != sourceID && !mapped && !layout.ContainsLeaf(destination.tree.Root, id) | |
| 96 | - | if next == math.MaxInt { | |
| 97 | - | return "", 0, errMovePaneIDExhausted | |
| 98 | - | } | |
| 99 | 96 | if available { | |
| 97 | + | if next == math.MaxInt { | |
| 98 | + | return id, next, nil | |
| 99 | + | } | |
| 100 | 100 | return id, next + 1, nil | |
| 101 | 101 | } | |
| 102 | + | if next == math.MaxInt { | |
| 103 | + | return "", 0, errMovePaneIDExhausted | |
| 104 | + | } | |
| 102 | 105 | next++ | |
| 103 | 106 | } | |
| 104 | 107 | } | |
| @@ -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, nil | 100 | 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 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "errors" | |
| 5 | 6 | "os" | |
| 6 | 7 | ||
| @@ -11,12 +12,20 @@ import ( | |||
| 11 | 12 | // movePanePostcommitPlan is captured while the move's architecture and owner | |
| 12 | 13 | // fences still protect publication. It contains only immutable pointers, | |
| 13 | 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 | 22 | type movePanePostcommitPlan struct { | |
| 15 | 23 | source *session | |
| 16 | 24 | destination *session | |
| 17 | 25 | sourceTab *tab | |
| 18 | 26 | destinationTab *tab | |
| 19 | 27 | movedPane *pane | |
| 28 | + | movedPanes []*pane | |
| 20 | 29 | destinationClient *attachedClient | |
| 21 | 30 | sourceCleanupToken attachmentRoleToken | |
| 22 | 31 | handoffResult attachmentTransitionResult | |
| @@ -25,6 +34,7 @@ type movePanePostcommitPlan struct { | |||
| 25 | 34 | rolesFrozen bool | |
| 26 | 35 | unlockDispatch func() | |
| 27 | 36 | reservation *moveLifecycleReservation | |
| 37 | + | oldTabCancel context.CancelFunc | |
| 28 | 38 | ||
| 29 | 39 | sourceTabWasActive bool | |
| 30 | 40 | sourceTabRemoved bool | |
| @@ -42,15 +52,23 @@ type movePanePostcommitPlan struct { | |||
| 42 | 52 | // released. Generation-bound tokens make stale effects drop rather than route | |
| 43 | 53 | // back to the retired source. | |
| 44 | 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 | 58 | p.sourceTab.cancel() | |
| 47 | 59 | } | |
| 48 | 60 | if p.sourceCleanupToken.current() { | |
| 49 | 61 | 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) | |
| 52 | 71 | } | |
| 53 | - | cleanupClient.pruneCaptureFrames(p.movedPane) | |
| 54 | 72 | } | |
| 55 | 73 | ||
| 56 | 74 | // Snapshot dirtiness is admitted while lifecycle reservations still pin | |
| @@ -66,10 +84,10 @@ func (p movePanePostcommitPlan) execute(d *Daemon) { | |||
| 66 | 84 | p.unlockDispatch() | |
| 67 | 85 | p.reservation.Release() | |
| 68 | 86 | ||
| 69 | - | if p.sourceTabWasActive && !moveSessionStillEmpty(d, p.source) { | |
| 87 | + | if p.sourceTabWasActive && !moveSessionRetired(d, p.source) { | |
| 70 | 88 | d.activateTab(p.source, p.source.activeTab()) | |
| 71 | 89 | } | |
| 72 | - | if !moveSessionStillEmpty(d, p.source) { | |
| 90 | + | if !moveSessionRetired(d, p.source) { | |
| 73 | 91 | sourceLayoutTab := p.sourceTab | |
| 74 | 92 | if p.sourceTabRemoved { | |
| 75 | 93 | sourceLayoutTab = p.source.activeTab() | |
| @@ -144,7 +162,7 @@ func retireEmptyMoveSessionLocked(sess *session, retirement frozenMoveAttachment | |||
| 144 | 162 | return retired | |
| 145 | 163 | } | |
| 146 | 164 | ||
| 147 | - | func moveSessionStillEmpty(d *Daemon, sess *session) bool { | |
| 165 | + | func moveSessionRetired(d *Daemon, sess *session) bool { | |
| 148 | 166 | if d == nil || sess == nil { | |
| 149 | 167 | return true | |
| 150 | 168 | } | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package daemon | 1 | 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 owner | 12 | // 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 *session | 23 | source *session | ||
| 16 | destination *session | 24 | destination *session | ||
| 17 | sourceTab *tab | 25 | sourceTab *tab | ||
| 18 | destinationTab *tab | 26 | destinationTab *tab | ||
| 19 | movedPane *pane | 27 | movedPane *pane | ||
| 28 | + | movedPanes []*pane | |||
| 20 | destinationClient *attachedClient | 29 | destinationClient *attachedClient | ||
| 21 | sourceCleanupToken attachmentRoleToken | 30 | sourceCleanupToken attachmentRoleToken | ||
| 22 | handoffResult attachmentTransitionResult | 31 | handoffResult attachmentTransitionResult | ||
| @@ -25,6 +34,7 @@ type movePanePostcommitPlan struct { | |||||
| 25 | rolesFrozen bool | 34 | rolesFrozen bool | ||
| 26 | unlockDispatch func() | 35 | unlockDispatch func() | ||
| 27 | reservation *moveLifecycleReservation | 36 | reservation *moveLifecycleReservation | ||
| 37 | + | oldTabCancel context.CancelFunc | |||
| 28 | 38 | ||||
| 29 | sourceTabWasActive bool | 39 | sourceTabWasActive bool | ||
| 30 | sourceTabRemoved bool | 40 | sourceTabRemoved bool | ||
| @@ -42,15 +52,23 @@ type movePanePostcommitPlan struct { | |||||
| 42 | // released. Generation-bound tokens make stale effects drop rather than route | 52 | // 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.ac | 61 | 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 pin | 74 | // 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.sourceTab | 91 | 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 retired | 162 | 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 true | 167 | return true | ||
| 150 | } | 168 | } | ||
M internal/usecase/daemon/move_persistence_test.go +43 -10
| @@ -79,18 +79,51 @@ type movePersistenceRepository struct { | |||
| 79 | 79 | func (r *movePersistenceRepository) Publish(context.Context, ports.SnapshotPublication) error { | |
| 80 | 80 | outsideLocks := true | |
| 81 | 81 | unlocks := make([]func(), 0, 7) | |
| 82 | - | for _, candidate := range []struct { | |
| 82 | + | candidates := make([]struct { | |
| 83 | 83 | tryLock func() bool | |
| 84 | 84 | 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 { | |
| 94 | 127 | if candidate.tryLock() { | |
| 95 | 128 | unlocks = append(unlocks, candidate.unlock) | |
| 96 | 129 | } 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 := true | 80 | 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() bool | 83 | 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) { | |||
| 89 | 89 | require.False(t, result.OK) | |
| 90 | 90 | require.Equal(t, descriptor.CommandCode, result.Code) | |
| 91 | 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 | 93 | require.Equal(t, tt.commandText, result.Text) | |
| 94 | 94 | }) | |
| 95 | 95 | } | |
| @@ -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 | |||
| 177 | 177 | return false | |
| 178 | 178 | } | |
| 179 | 179 | d.observeBeforeResizeOwnerPostEffect(effect) | |
| 180 | + | if len(members) == 0 { | |
| 181 | + | publish() | |
| 182 | + | return true | |
| 183 | + | } | |
| 180 | 184 | fences := acquireResizeOwnerPostEffectFences(members) | |
| 181 | 185 | if fences == nil { | |
| 182 | 186 | return false | |
| @@ -177,6 +177,10 @@ func (d *Daemon) publishResizeOwnerPostEffect(members []resizeMember, effect res | |||||
| 177 | return false | 177 | 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 false | 186 | return false | ||
M internal/usecase/daemon/move_stale_destination_test.go +14 -3
| @@ -28,8 +28,12 @@ func TestMovePaneRejectsStaleDestinationIncarnationWithoutMutation(t *testing.T) | |||
| 28 | 28 | d.sessions[destination.id] = destination | |
| 29 | 29 | d.mu.Unlock() | |
| 30 | 30 | ||
| 31 | + | sourceTab.mu.Lock() | |
| 31 | 32 | beforeSource := sourceTab.tree.Clone() | |
| 33 | + | sourceTab.mu.Unlock() | |
| 34 | + | destinationTab.mu.Lock() | |
| 32 | 35 | beforeDestination := destinationTab.tree.Clone() | |
| 36 | + | destinationTab.mu.Unlock() | |
| 33 | 37 | beforeOwner := moved.ownerSnapshot() | |
| 34 | 38 | staleIncarnation := destination.incarnation | |
| 35 | 39 | staleIncarnation[0]++ | |
| @@ -41,8 +45,15 @@ func TestMovePaneRejectsStaleDestinationIncarnationWithoutMutation(t *testing.T) | |||
| 41 | 45 | DestinationTabID: domain.TabStableID(destinationTab.stableID), | |
| 42 | 46 | }) | |
| 43 | 47 | 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) | |
| 46 | 57 | require.Same(t, beforeOwner, moved.ownerSnapshot()) | |
| 47 | - | require.Same(t, moved, sourceTab.panes[moved.id]) | |
| 58 | + | require.Same(t, moved, sourceMoved) | |
| 48 | 59 | } | |
| @@ -28,8 +28,12 @@ func TestMovePaneRejectsStaleDestinationIncarnationWithoutMutation(t *testing.T) | |||||
| 28 | d.sessions[destination.id] = destination | 28 | 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.incarnation | 38 | 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 ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | ||
| 6 | 6 | "github.com/bnema/vev/internal/domain" | |
| 7 | - | "github.com/bnema/vev/internal/ports" | |
| 8 | 7 | "github.com/bnema/vev/internal/usecase/layout" | |
| 9 | 8 | ) | |
| 10 | 9 | ||
| @@ -47,6 +46,27 @@ type moveTabCommit struct { | |||
| 47 | 46 | 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 | 70 | func (d *Daemon) moveTab(req moveTabRequest) (result error) { | |
| 51 | 71 | defer func() { result = normalizeMoveRejection(result) }() | |
| 52 | 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 | 129 | if err != nil { | |
| 110 | 130 | return err | |
| 111 | 131 | } | |
| 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) { | |
| 123 | 136 | if d.afterRoleEffectGateFrozen != nil { | |
| 124 | 137 | d.afterRoleEffectGateFrozen("move-tab", ac) | |
| 125 | 138 | } | |
| 126 | - | }, participants.clients...) | |
| 139 | + | }}, participants.clients...) | |
| 127 | 140 | if !frozen.acquired || !frozen.drained { | |
| 128 | 141 | frozen.unfreeze() | |
| 129 | 142 | return errMovePaneInvalid | |
| @@ -132,18 +145,8 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) { | |||
| 132 | 145 | commit.sourceRolesFrozen = true | |
| 133 | 146 | commit.handoffReq.roleEffectsFrozen = true | |
| 134 | 147 | } 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...) | |
| 147 | 150 | if !frozen.acquired || !frozen.drained { | |
| 148 | 151 | frozen.unfreeze() | |
| 149 | 152 | return errMovePaneInvalid | |
| @@ -167,72 +170,35 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) { | |||
| 167 | 170 | return errMoveStaleTarget | |
| 168 | 171 | } | |
| 169 | 172 | 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 | |
| 189 | 199 | dispatchHeld = false | |
| 190 | - | reservation.Release() | |
| 191 | 200 | 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) | |
| 236 | 202 | return nil | |
| 237 | 203 | } | |
| 238 | 204 | ||
| @@ -260,6 +226,9 @@ func (d *Daemon) snapshotMoveTabAdmission(req moveTabRequest, source, destinatio | |||
| 260 | 226 | if !moved.floatingTransferableLocked() { | |
| 261 | 227 | return nil, errMoveFloatingWarming | |
| 262 | 228 | } | |
| 229 | + | if moved.tree == nil || moved.tree.Root == nil { | |
| 230 | + | return nil, errMovePaneInvalid | |
| 231 | + | } | |
| 263 | 232 | if _, ok := layout.Solve(moved.tree.Root, domain.Rect{Width: destinationSize.Cols, Height: destinationSize.Rows}); !ok { | |
| 264 | 233 | return nil, errMoveTooSmall | |
| 265 | 234 | } | |
| @@ -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 error | 46 | 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 err | 130 | 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 errMovePaneInvalid | 142 | return errMovePaneInvalid | ||
| @@ -132,18 +145,8 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) { | |||||
| 132 | commit.sourceRolesFrozen = true | 145 | commit.sourceRolesFrozen = true | ||
| 133 | commit.handoffReq.roleEffectsFrozen = true | 146 | 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 errMovePaneInvalid | 152 | return errMovePaneInvalid | ||
| @@ -167,72 +170,35 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) { | |||||
| 167 | return errMoveStaleTarget | 170 | 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 = false | 189 | + | 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 = false | 199 | dispatchHeld = false | ||
| 190 | - | reservation.Release() | |||
| 191 | reservationHeld = false | 200 | 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 nil | 202 | 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, errMoveFloatingWarming | 227 | 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, errMoveTooSmall | 233 | return nil, errMoveTooSmall | ||
| 265 | } | 234 | } | ||
M internal/usecase/daemon/move_tab_handoff_test.go +10 -12
| @@ -10,7 +10,7 @@ import ( | |||
| 10 | 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 | 14 | t.Helper() | |
| 15 | 15 | d, source, follower, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 16 | 16 | follower.replaceTransport(&closeTrackingTransport{}) | |
| @@ -30,7 +30,7 @@ func setupMoveTabFinalSourceHandoff(t *testing.T) (*Daemon, *session, *attachedC | |||
| 30 | 30 | destination.mu.Unlock() | |
| 31 | 31 | require.NotNil(t, d.attachCoordinator(destination, nil, displaced, true)) | |
| 32 | 32 | ||
| 33 | - | return d, source, follower, destination, displaced, moved | |
| 33 | + | return d, source, follower, destination, displaced, displacedTransport, moved | |
| 34 | 34 | } | |
| 35 | 35 | ||
| 36 | 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 | 58 | 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) | |
| 61 | 60 | ||
| 62 | 61 | moveTabFinalSource(t, d, source, destination, moved) | |
| 63 | 62 | d.attachmentCleanupWg.Wait() | |
| @@ -82,7 +81,7 @@ func TestMoveTabFinalSourceSnatchesDestinationActiveClient(t *testing.T) { | |||
| 82 | 81 | } | |
| 83 | 82 | ||
| 84 | 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 | 86 | waitingTransport := &closeTrackingTransport{} | |
| 88 | 87 | waiting := &attachedClient{tr: waitingTransport, output: newOutputStateStream(), size: follower.size} | |
| @@ -104,7 +103,7 @@ func TestMoveTabFinalSourcePreservesDestinationSnatchedWaiters(t *testing.T) { | |||
| 104 | 103 | } | |
| 105 | 104 | ||
| 106 | 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 | 108 | parkedTransport := &closeTrackingTransport{} | |
| 110 | 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 | 133 | func TestMoveTabFinalSourceClearsFollowerCopyCapturePreservesDestinationWaiterOverlays(t *testing.T) { | |
| 135 | - | d, source, follower, destination, _, moved := setupMoveTabFinalSourceHandoff(t) | |
| 134 | + | d, source, follower, destination, _, _, moved := setupMoveTabFinalSourceHandoff(t) | |
| 136 | 135 | movedPane := moved.focusedPane() | |
| 137 | 136 | ||
| 138 | 137 | follower.overlays.copyMu.Lock() | |
| @@ -182,7 +181,7 @@ func TestMoveTabFinalSourceClearsFollowerCopyCapturePreservesDestinationWaiterOv | |||
| 182 | 181 | } | |
| 183 | 182 | ||
| 184 | 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 | 185 | followerRebased := make(chan struct{}) | |
| 187 | 186 | follower.renderStages.handoffRebase = func() { close(followerRebased) } | |
| 188 | 187 | ||
| @@ -206,7 +205,7 @@ func TestMoveTabFinalSourceFirstPaintNotBlockedByDisplacedPanelCleanup(t *testin | |||
| 206 | 205 | } | |
| 207 | 206 | ||
| 208 | 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 | 210 | paintAdmitted := make(chan struct{}) | |
| 212 | 211 | releasePaint := make(chan struct{}) | |
| @@ -252,7 +251,7 @@ func TestMoveTabFinalSourceConcurrentReclaimKeepsNewestOwnerAndLease(t *testing. | |||
| 252 | 251 | } | |
| 253 | 252 | ||
| 254 | 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 | 256 | d.afterRoleEffectAdmitted = func(token attachmentRoleToken) { | |
| 258 | 257 | if token.ac == follower { | |
| @@ -272,8 +271,7 @@ func TestMoveTabFinalSourcePostCommitPaintFailureDoesNotRollback(t *testing.T) { | |||
| 272 | 271 | } | |
| 273 | 272 | ||
| 274 | 273 | 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) | |
| 277 | 275 | ||
| 278 | 276 | commitEntered := make(chan struct{}) | |
| 279 | 277 | 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, moved | 33 | + | 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 ( | |||
| 10 | 10 | "github.com/stretchr/testify/require" | |
| 11 | 11 | ||
| 12 | 12 | "github.com/bnema/vev/internal/domain" | |
| 13 | - | "github.com/bnema/vev/internal/ports" | |
| 14 | 13 | recoveryusecase "github.com/bnema/vev/internal/usecase/recovery" | |
| 15 | 14 | "github.com/bnema/vev/pkg/renderer" | |
| 16 | 15 | ) | |
| @@ -79,7 +78,7 @@ func TestMoveTabPreservesWholeTabAndActivatesLogicalNeighbor(t *testing.T) { | |||
| 79 | 78 | require.Same(t, tiledScreen, tiled.screen) | |
| 80 | 79 | require.Same(t, floating, moved.floating.pane) | |
| 81 | 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 | 82 | require.Equal(t, uint64(7), moved.floating.generation) | |
| 84 | 83 | require.Equal(t, tiledOwnerGeneration+1, tiled.ownerGeneration) | |
| 85 | 84 | require.Equal(t, floatingOwnerGeneration+1, floating.ownerGeneration) | |
| @@ -413,12 +412,21 @@ func TestMoveTabOppositeDirectionsDoNotDeadlock(t *testing.T) { | |||
| 413 | 412 | } | |
| 414 | 413 | ||
| 415 | 414 | type moveTabPurgeCatalogue struct { | |
| 416 | - | ports.Catalogue | |
| 417 | 415 | mu sync.Mutex | |
| 418 | 416 | records map[string]domain.CatalogueRecord | |
| 419 | 417 | 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 | 430 | func (c *moveTabPurgeCatalogue) Record(name string) (domain.CatalogueRecord, bool, error) { | |
| 423 | 431 | c.mu.Lock() | |
| 424 | 432 | defer c.mu.Unlock() | |
| @@ -426,6 +434,18 @@ func (c *moveTabPurgeCatalogue) Record(name string) (domain.CatalogueRecord, boo | |||
| 426 | 434 | 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 | 449 | func (c *moveTabPurgeCatalogue) Delete(name string) error { | |
| 430 | 450 | c.mu.Lock() | |
| 431 | 451 | defer c.mu.Unlock() | |
| @@ -441,6 +461,9 @@ func (c *moveTabPurgeCatalogue) UpdateMetadata(update domain.CatalogueMetadataUp | |||
| 441 | 461 | return nil | |
| 442 | 462 | } | |
| 443 | 463 | ||
| 464 | + | func (c *moveTabPurgeCatalogue) Sync() error { return nil } | |
| 465 | + | func (c *moveTabPurgeCatalogue) Close() error { return nil } | |
| 466 | + | ||
| 444 | 467 | type moveTabPurgeRepository struct { | |
| 445 | 468 | noOpSnapshotRepository | |
| 446 | 469 | 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.Mutex | 415 | mu sync.Mutex | ||
| 418 | records map[string]domain.CatalogueRecord | 416 | records map[string]domain.CatalogueRecord | ||
| 419 | events []string | 417 | 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, nil | 434 | 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 nil | 461 | 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 | noOpSnapshotRepository | 468 | noOpSnapshotRepository | ||
| 446 | d *Daemon | 469 | d *Daemon | ||
M internal/usecase/daemon/move_test.go +5 -4
| @@ -3,6 +3,7 @@ package daemon | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "os" | |
| 6 | + | "sync/atomic" | |
| 6 | 7 | "testing" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/stretchr/testify/require" | |
| @@ -94,8 +95,8 @@ func TestMovePaneFinalSourceClientFollowsDestination(t *testing.T) { | |||
| 94 | 95 | ||
| 95 | 96 | rc := d.attachCoordinator(source, nil, client, true) | |
| 96 | 97 | 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) } | |
| 99 | 100 | destination := &session{id: "destination", name: "destination", tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}, active: 0} | |
| 100 | 101 | destinationTab := destination.tabs[0] | |
| 101 | 102 | destinationTab.stableID = "destination-tab" | |
| @@ -117,8 +118,8 @@ func TestMovePaneFinalSourceClientFollowsDestination(t *testing.T) { | |||
| 117 | 118 | require.Same(t, client, destination.client) | |
| 118 | 119 | require.Equal(t, attachmentActive, destination.attachmentRole(client)) | |
| 119 | 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, 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") | |
| 122 | 123 | } | |
| 123 | 124 | ||
| 124 | 125 | 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 := false | 98 | + | 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 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "sync" | |
| 4 | 5 | "testing" | |
| 5 | 6 | "time" | |
| 6 | 7 | ||
| @@ -105,9 +106,10 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) { | |||
| 105 | 106 | ||
| 106 | 107 | admissionEnded := make(chan struct{}) | |
| 107 | 108 | releaseAction := make(chan struct{}) | |
| 109 | + | var admissionEndedOnce sync.Once | |
| 108 | 110 | d.afterActionRoleEffectEnded = func(action string) { | |
| 109 | 111 | if action == tt.action { | |
| 110 | - | close(admissionEnded) | |
| 112 | + | admissionEndedOnce.Do(func() { close(admissionEnded) }) | |
| 111 | 113 | <-releaseAction | |
| 112 | 114 | } | |
| 113 | 115 | } | |
| @@ -159,9 +161,10 @@ func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing. | |||
| 159 | 161 | ||
| 160 | 162 | ended := make(chan struct{}) | |
| 161 | 163 | release := make(chan struct{}) | |
| 164 | + | var endedOnce sync.Once | |
| 162 | 165 | d.afterActionRoleEffectEnded = func(action string) { | |
| 163 | 166 | if action == "picker-stopped" { | |
| 164 | - | close(ended) | |
| 167 | + | endedOnce.Do(func() { close(ended) }) | |
| 165 | 168 | <-release | |
| 166 | 169 | } | |
| 167 | 170 | } | |
| @@ -170,7 +173,11 @@ func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing. | |||
| 170 | 173 | go func() { | |
| 171 | 174 | done <- d.switchToTargetForRole(effect.roleToken(), picker.Target{Name: "stopped", Stopped: true, ExpectedCreatedAt: &expectedCreatedAt}, sessionHandoffGuard{}, "picker-stopped") | |
| 172 | 175 | }() | |
| 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 | + | } | |
| 174 | 181 | ||
| 175 | 182 | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 176 | 183 | next.initOverlays() | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package daemon | 1 | 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 | <-releaseAction | 113 | <-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 | <-release | 168 | <-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 | - | <-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 | + | } | |||
| 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 | |||
| 66 | 66 | go func() { | |
| 67 | 67 | done <- d.switchToTargetForRole(effect.roleToken(), picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "test-handoff") | |
| 68 | 68 | }() | |
| 69 | - | <-ended | |
| 69 | + | awaitTestCompletion(t, ended, "test handoff did not release its role ticket") | |
| 70 | 70 | tt.rebind(t, d, source, ac) | |
| 71 | 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 | 74 | require.Same(t, source, ac.currentSession()) | |
| 75 | 75 | 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 | - | <-ended | 69 | + | 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 | |||
| 359 | 359 | 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 | 363 | target := resolveDaemonActionTarget(sess) | |
| 364 | 364 | oldFocus := layout.PaneID("") | |
| 365 | 365 | if target.pane != nil { | |
| @@ -384,8 +384,8 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio | |||
| 384 | 384 | if ac == nil { | |
| 385 | 385 | return errNoNeighbor | |
| 386 | 386 | } | |
| 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") | |
| 389 | 389 | } | |
| 390 | 390 | return d.commitSessionOverflow(sess, ac, target.tab, sessionTarget) | |
| 391 | 391 | } | |
| @@ -359,7 +359,7 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache | |||||
| 359 | return nil | 359 | 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 errNoNeighbor | 385 | 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) { | |||
| 625 | 625 | ac.initOverlays() | |
| 626 | 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 | 630 | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus) | |
| 631 | 631 | require.Nil(t, ac.overlays.copyMode) | |
| @@ -726,7 +726,7 @@ func TestStackFocusWalkExpandsAndOverflowRefuses(t *testing.T) { | |||
| 726 | 726 | require.NoError(t, d.stackPane(sess, nil)) | |
| 727 | 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 | 731 | tb := sess.activeTab() | |
| 732 | 732 | 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) { | |||
| 174 | 174 | } | |
| 175 | 175 | tb.mu.Lock() | |
| 176 | 176 | 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 | 183 | publishedFailedPTY := false | |
| 178 | 184 | for _, published := range tb.panes { | |
| 179 | 185 | publishedFailedPTY = publishedFailedPTY || published.pty == pty | |
| 180 | 186 | } | |
| 181 | 187 | tb.mu.Unlock() | |
| 182 | - | require.NotSame(t, pty, floating) | |
| 188 | + | require.NotEqual(t, pty, floatingPTY) | |
| 183 | 189 | require.False(t, publishedFailedPTY, "failed Open must not publish a pane or owner") | |
| 184 | 190 | } | |
| 185 | 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 | 212 | return f.pty, f.err | |
| 207 | 213 | } | |
| 208 | 214 | ||
| 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 | + | }, | |
| 343 | 298 | } | |
| 344 | 299 | ||
| 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 | + | }() | |
| 354 | 310 | ||
| 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) | |
| 366 | 316 | ||
| 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 | + | } | |
| 373 | 323 | ||
| 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 | + | }) | |
| 379 | 334 | } | |
| 380 | - | require.Equal(t, int32(1), pty.closes.Load()) | |
| 381 | 335 | } | |
| @@ -174,12 +174,18 @@ func TestPaneOpenErrorsReleaseReturnedPTYWithoutPublication(t *testing.T) { | |||||
| 174 | } | 174 | } | ||
| 175 | tb.mu.Lock() | 175 | tb.mu.Lock() | ||
| 176 | floating := tb.floating.pane | 176 | 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 := false | 183 | publishedFailedPTY := false | ||
| 178 | for _, published := range tb.panes { | 184 | for _, published := range tb.panes { | ||
| 179 | publishedFailedPTY = publishedFailedPTY || published.pty == pty | 185 | 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.err | 212 | 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] = sess | 256 | + | 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 ...* | |||
| 231 | 231 | d.invalidateRender(sess, ac, true, "picker.go") | |
| 232 | 232 | return | |
| 233 | 233 | } | |
| 234 | + | if len(effects) != 0 && effects[0] != nil { | |
| 235 | + | effects[0].End() | |
| 236 | + | } | |
| 234 | 237 | d.closePicker(ac) | |
| 235 | 238 | err := d.commitMovePickerSelection(intent, source, target) | |
| 236 | 239 | 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 | return | 232 | 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 { | |||
| 136 | 136 | attention bool | |
| 137 | 137 | responses []byte | |
| 138 | 138 | clipboards []string | |
| 139 | + | syncCleanup syncTimerCleanup | |
| 139 | 140 | } | |
| 140 | 141 | ||
| 141 | 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 | 161 | effects := panePTYEffects{lease: p.effectLeaseLocked()} | |
| 161 | 162 | owner := effects.lease.owner | |
| 162 | - | if owner != nil { | |
| 163 | + | if owner != nil && owner.session != nil { | |
| 163 | 164 | effects.renderCoordinator = owner.session.renderCoordinator() | |
| 164 | 165 | } | |
| 165 | 166 | effects.wasSyncing = p.screen.SyncUpdateActive() | |
| @@ -167,13 +168,13 @@ func (d *Daemon) processPanePTYData(p *pane, data []byte, bufferDuringApply bool | |||
| 167 | 168 | p.refreshTerminalTitleLocked() | |
| 168 | 169 | effects.isSyncing = p.screen.SyncUpdateActive() | |
| 169 | 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 | 172 | if effects.isSyncing { | |
| 172 | 173 | syncGen := owner.session.syncGen.Add(1) | |
| 173 | 174 | p.syncGen = syncGen | |
| 174 | 175 | if effects.renderCoordinator != nil { | |
| 175 | 176 | lease := effects.lease | |
| 176 | - | effects.renderCoordinator.noteSyncBeginWithRenderability(p, syncGen, func() bool { | |
| 177 | + | effects.syncCleanup = effects.renderCoordinator.beginSyncBatchWithRenderability(p, syncGen, func() bool { | |
| 177 | 178 | return lease.Current() && d.paneRenderable(owner.session, owner.tab, p) | |
| 178 | 179 | }, func() { | |
| 179 | 180 | p.mu.Lock() | |
| @@ -194,6 +195,7 @@ func (d *Daemon) processPanePTYData(p *pane, data []byte, bufferDuringApply bool | |||
| 194 | 195 | effects.clipboards = append([]string(nil), p.ptyClipboards...) | |
| 195 | 196 | p.ptyClipboards = p.ptyClipboards[:0] | |
| 196 | 197 | p.mu.Unlock() | |
| 198 | + | effects.syncCleanup.finish() | |
| 197 | 199 | d.applyPanePTYEffects(p, effects) | |
| 198 | 200 | } | |
| 199 | 201 | ||
| @@ -203,7 +205,7 @@ func (d *Daemon) processPanePTYData(p *pane, data []byte, bufferDuringApply bool | |||
| 203 | 205 | // bytes that belonged to the old owner. | |
| 204 | 206 | func (d *Daemon) applyPanePTYEffects(p *pane, effects panePTYEffects) { | |
| 205 | 207 | owner := effects.lease.owner | |
| 206 | - | if owner == nil { | |
| 208 | + | if owner == nil || owner.session == nil || owner.tab == nil { | |
| 207 | 209 | return | |
| 208 | 210 | } | |
| 209 | 211 | if effects.lease.Current() { | |
| @@ -136,6 +136,7 @@ type panePTYEffects struct { | |||||
| 136 | attention bool | 136 | attention bool | ||
| 137 | responses []byte | 137 | responses []byte | ||
| 138 | clipboards []string | 138 | 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.owner | 162 | 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 = syncGen | 174 | p.syncGen = syncGen | ||
| 174 | if effects.renderCoordinator != nil { | 175 | if effects.renderCoordinator != nil { | ||
| 175 | lease := effects.lease | 176 | 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.owner | 207 | owner := effects.lease.owner | ||
| 206 | - | if owner == nil { | 208 | + | if owner == nil || owner.session == nil || owner.tab == nil { |
| 207 | return | 209 | 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) { | |||
| 1334 | 1334 | rc.mu.Unlock() | |
| 1335 | 1335 | } | |
| 1336 | 1336 | ||
| 1337 | - | func TestRenderCoordinatorNilClockRetryCompletesWithoutSynchronousRun(t *testing.T) { | |
| 1337 | + | func TestRenderCoordinatorNilClockRetryRunsSynchronously(t *testing.T) { | |
| 1338 | 1338 | rc := newRenderCoordinator(renderCoordinatorOptions{}) | |
| 1339 | 1339 | owner := &attachedClient{} | |
| 1340 | 1340 | rc.attach(owner) | |
| @@ -1345,7 +1345,7 @@ func TestRenderCoordinatorNilClockRetryCompletesWithoutSynchronousRun(t *testing | |||
| 1345 | 1345 | ||
| 1346 | 1346 | called := false | |
| 1347 | 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 | 1349 | rc.mu.Lock() | |
| 1350 | 1350 | retryToken := rc.retryLane.token | |
| 1351 | 1351 | 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 := false | 1346 | 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.token | 1350 | 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 { | |||
| 80 | 80 | source *attachedClient | |
| 81 | 81 | // lease binds every delayed resize/retry callback to the exact attachment | |
| 82 | 82 | // 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 | |
| 86 | 87 | } | |
| 87 | 88 | ||
| 88 | 89 | // renderCoordinatorOptions wires one coordinator instance. | |
| @@ -105,6 +106,9 @@ type renderCoordinatorOptions struct { | |||
| 105 | 106 | // afterSyncGateEvaluated is a package-private deterministic test seam. It | |
| 106 | 107 | // runs unlocked after visibility predicates and before registry validation. | |
| 107 | 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 | 114 | // renderCoordinator fans in producer invalidations for one attached session. | |
| @@ -80,9 +80,10 @@ type resizeRequestMetadata struct { | |||||
| 80 | source *attachedClient | 80 | source *attachedClient | ||
| 81 | // lease binds every delayed resize/retry callback to the exact attachment | 81 | // 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 *attachmentLease | 83 | + | lease *attachmentLease |
| 84 | - | epoch uint64 // latest requested epoch | 84 | + | epoch uint64 // latest requested epoch |
| 85 | - | committed uint64 // latest published epoch | 85 | + | 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. It | 106 | // 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 ( | |||
| 5 | 5 | "github.com/bnema/vev/internal/ports" | |
| 6 | 6 | ) | |
| 7 | 7 | ||
| 8 | + | const maxResizeRetryAttempts = 3 | |
| 9 | + | ||
| 8 | 10 | // Resize deadlines and retries retain coordinator-owned attachment, epoch, and | |
| 9 | 11 | // token validation at every callback effect boundary. | |
| 10 | 12 | func (c *renderCoordinator) recordResizeRequest(size domain.Size, source *attachedClient) uint64 { | |
| @@ -20,6 +22,7 @@ func (c *renderCoordinator) recordResizeRequestForLease(size domain.Size, source | |||
| 20 | 22 | _, retry := c.retryLane.replaceLocked() | |
| 21 | 23 | c.resize.epoch++ | |
| 22 | 24 | c.resize.size, c.resize.source, c.resize.lease = size, source, lease | |
| 25 | + | c.resize.retryAttempts = 0 | |
| 23 | 26 | epoch := c.resize.epoch | |
| 24 | 27 | c.mu.Unlock() | |
| 25 | 28 | stopDetachedTimer(retry) | |
| @@ -109,9 +112,10 @@ type resizeRetryReservation struct { | |||
| 109 | 112 | func (c *renderCoordinator) reserveResizeRetryForLease(epoch uint64, source *attachedClient, lease *attachmentLease, run func()) (*resizeRetryReservation, bool) { | |
| 110 | 113 | c.mu.Lock() | |
| 111 | 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 | 116 | return nil, false | |
| 114 | 117 | } | |
| 118 | + | c.resize.retryAttempts++ | |
| 115 | 119 | gen, old := c.retryLane.replaceLocked() | |
| 116 | 120 | 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 | 127 | c := r.coordinator | |
| 124 | 128 | stopDetachedTimer(r.old) | |
| 125 | 129 | if r.clock == nil { | |
| 130 | + | r.run() | |
| 126 | 131 | return | |
| 127 | 132 | } | |
| 128 | 133 | timer := r.clock.NewTimer(minOutputRenderDeadline) | |
| @@ -164,6 +169,12 @@ func (c *renderCoordinator) scheduleResizeRetryForLease(epoch uint64, source *at | |||
| 164 | 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 | 178 | func (c *renderCoordinator) retryCurrentForLease(epoch uint64, source *attachedClient, lease *attachmentLease) bool { | |
| 168 | 179 | c.mu.Lock() | |
| 169 | 180 | 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, and | 10 | // 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, lease | 24 | c.resize.size, c.resize.source, c.resize.lease = size, source, lease | ||
| 25 | + | c.resize.retryAttempts = 0 | |||
| 23 | epoch := c.resize.epoch | 26 | 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, false | 116 | 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}, true | 120 | 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.coordinator | 127 | 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 | return | 131 | 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 { | |||
| 24 | 24 | if p.attachment == nil { | |
| 25 | 25 | 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 | 32 | p.role.ac != p.attachment || p.role.role != attachmentActive || p.role.lease != p.lease || | |
| 29 | 33 | p.role.generation != p.attachment.roleGeneration.Load() || | |
| 30 | 34 | !p.attachment.transportSnapshotCurrent(p.role.transport) || | |
| @@ -56,6 +60,9 @@ func (p resizeCommitPublication) emit() bool { | |||
| 56 | 60 | // the canonical rule never acquires an attachment lock while a pane fence is | |
| 57 | 61 | // held. Observer callbacks run only after both lock families are released. | |
| 58 | 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 | 66 | d.observeBeforeResizeOwnerPostEffect(resizeOwnerPostCommitPublication) | |
| 60 | 67 | if ac != nil { | |
| 61 | 68 | ac.sendMu.Lock() | |
| @@ -130,7 +137,7 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease * | |||
| 130 | 137 | if !d.publishResizeCommit(result.members, sess, ac, lease, epoch, ticket, snap.size) { | |
| 131 | 138 | return false | |
| 132 | 139 | } | |
| 133 | - | if len(result.failed) != 0 { | |
| 140 | + | if len(result.failed) != 0 && rc.resizeRetryAvailableForLease(epoch, ac, lease) { | |
| 134 | 141 | var retry *resizeRetryReservation | |
| 135 | 142 | if !d.publishResizeOwnerPostEffect(result.failed, resizeOwnerPostRetrySchedule, func() { | |
| 136 | 143 | retry, _ = rc.reserveResizeRetryForLease(epoch, ac, lease, func() { | |
| @@ -173,7 +180,7 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *at | |||
| 173 | 180 | tabs := make([]*tab, 0, len(members)) | |
| 174 | 181 | seen := make(map[*tab]struct{}, len(members)) | |
| 175 | 182 | for _, member := range members { | |
| 176 | - | if member.tab == nil || member.isFloating || !resizeMemberOwnerCurrentLocked(&member) { | |
| 183 | + | if member.tab == nil || member.isFloating || !resizeMemberOwnerCurrent(&member) { | |
| 177 | 184 | continue | |
| 178 | 185 | } | |
| 179 | 186 | if _, ok := seen[member.tab]; ok { | |
| @@ -233,7 +240,7 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *at | |||
| 233 | 240 | tb.mu.Unlock() | |
| 234 | 241 | } | |
| 235 | 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 | 244 | continue | |
| 238 | 245 | } | |
| 239 | 246 | if !rc.retryCurrentForLease(epoch, ac, lease) { | |
| @@ -246,7 +253,7 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *at | |||
| 246 | 253 | currentSlot := member.tab.floating.state == floatingVisible && | |
| 247 | 254 | member.tab.floating.generation == member.floatingGeneration && | |
| 248 | 255 | member.tab.floating.pane == member.pane && | |
| 249 | - | resizeMemberOwnerCurrentLocked(&member) | |
| 256 | + | resizeMemberOwnerCurrent(&member) | |
| 250 | 257 | retryPending := false | |
| 251 | 258 | if currentSlot { | |
| 252 | 259 | member.pane.mu.Lock() | |
| @@ -268,7 +275,7 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *at | |||
| 268 | 275 | stillCurrent := member.tab.floating.state == floatingVisible && | |
| 269 | 276 | member.tab.floating.generation == member.floatingGeneration && | |
| 270 | 277 | member.tab.floating.pane == member.pane && | |
| 271 | - | resizeMemberOwnerCurrentLocked(&member) | |
| 278 | + | resizeMemberOwnerCurrent(&member) | |
| 272 | 279 | member.tab.mu.Unlock() | |
| 273 | 280 | if stillCurrent && len(result.failed) == 0 { | |
| 274 | 281 | member.pane.mu.Lock() | |
| @@ -276,7 +283,7 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *at | |||
| 276 | 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 | 287 | var retry *resizeRetryReservation | |
| 281 | 288 | if !d.publishResizeOwnerPostEffect(failed, resizeOwnerPostRetrySchedule, func() { | |
| 282 | 289 | 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 true | 25 | 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 is | 60 | // 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 false | 138 | return false | ||
| 132 | } | 139 | } | ||
| 133 | - | if len(result.failed) != 0 { | 140 | + | if len(result.failed) != 0 && rc.resizeRetryAvailableForLease(epoch, ac, lease) { |
| 134 | var retry *resizeRetryReservation | 141 | 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 | continue | 184 | 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 | continue | 244 | 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 := false | 257 | 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 *resizeRetryReservation | 287 | 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 { | |||
| 23 | 23 | } | |
| 24 | 24 | for i := range t.members { | |
| 25 | 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 | 27 | return false | |
| 28 | 28 | } | |
| 29 | 29 | } | |
| @@ -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 false | 27 | return false | ||
| 28 | } | 28 | } | ||
| 29 | } | 29 | } | ||
M internal/usecase/daemon/resume.go +26 -3
| @@ -3,11 +3,14 @@ package daemon | |||
| 3 | 3 | import ( | |
| 4 | 4 | "crypto/rand" | |
| 5 | 5 | "encoding/binary" | |
| 6 | + | "errors" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/vev/internal/domain" | |
| 8 | 9 | "github.com/bnema/vev/internal/ports" | |
| 9 | 10 | ) | |
| 10 | 11 | ||
| 12 | + | var errResumeTokenLifecycleRace = errors.New("resume token lifecycle race") | |
| 13 | + | ||
| 11 | 14 | func newResumeToken() uint64 { | |
| 12 | 15 | var b [8]byte | |
| 13 | 16 | if _, err := rand.Read(b[:]); err != nil { | |
| @@ -70,9 +73,17 @@ func (d *Daemon) parkAttachmentAs(sess *session, ac *attachedClient, role attach | |||
| 70 | 73 | sess.mu.Unlock() | |
| 71 | 74 | } | |
| 72 | 75 | token := ac.resumeToken | |
| 76 | + | var ( | |
| 77 | + | oldRetirement *parkedAttachmentRetirement | |
| 78 | + | oldSame *parkedAttachment | |
| 79 | + | ) | |
| 73 | 80 | 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 | + | } | |
| 76 | 87 | } | |
| 77 | 88 | grace := d.resumeParkGrace | |
| 78 | 89 | timer := d.clock.NewTimer(grace) | |
| @@ -80,6 +91,15 @@ func (d *Daemon) parkAttachmentAs(sess *session, ac *attachedClient, role attach | |||
| 80 | 91 | ac.parked = true | |
| 81 | 92 | d.parked[token] = parked | |
| 82 | 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 | 103 | d.log.Info("client parked for resume", "session", sess.name, "grace", grace) | |
| 84 | 104 | ||
| 85 | 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 | 226 | return nil, nil, false, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"} | |
| 207 | 227 | } | |
| 208 | 228 | 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"} | |
| 210 | 233 | } | |
| 211 | 234 | return d.resumeParkedLocked(h, tr, sz) | |
| 212 | 235 | } | |
| @@ -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]byte | 15 | 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.resumeToken | 75 | 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.resumeParkGrace | 88 | 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 = true | 91 | ac.parked = true | ||
| 81 | d.parked[token] = parked | 92 | 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, 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"} | |||
| 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 { | |||
| 86 | 86 | transport transportSnapshot | |
| 87 | 87 | } | |
| 88 | 88 | ||
| 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 | |
| 113 | 97 | } | |
| 114 | 98 | ||
| 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...) | |
| 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 | 111 | ordered := orderedRoleEffectClients(clients) | |
| 128 | - | frozen, acquired := acquireRoleEffectGates(ordered, done, afterFrozen, nonblocking) | |
| 112 | + | frozen, acquired := acquireRoleEffectGates(ordered, options.done, options.afterFrozen, options.nonblocking) | |
| 129 | 113 | if !acquired { | |
| 130 | 114 | return frozenRoleEffectGates{} | |
| 131 | 115 | } | |
| 132 | 116 | ||
| 133 | - | interruptByClient := roleTransportInterruptsByClient(interrupts) | |
| 117 | + | interruptByClient := roleTransportInterruptsByClient(options.interrupts) | |
| 134 | 118 | frozen.interruptions = make(map[*attachedClient]transportSnapshot, len(interruptByClient)) | |
| 135 | 119 | interruptFrozenRoleTransports(ordered, interruptByClient) | |
| 136 | 120 | frozen.acquired = true | |
| 137 | - | frozen.drained = drainFrozenRoleEffects(ordered, interruptByClient, frozen.interruptions, done) | |
| 121 | + | frozen.drained = drainFrozenRoleEffects(ordered, interruptByClient, frozen.interruptions, options.done) | |
| 138 | 122 | return frozen | |
| 139 | 123 | } | |
| 140 | 124 | ||
| @@ -86,55 +86,39 @@ type roleTransportInterrupt struct { | |||||
| 86 | transport transportSnapshot | 86 | transport transportSnapshot | ||
| 87 | } | 87 | } | ||
| 88 | 88 | ||||
| 89 | - | // freezeRoleEffectGates establishes transition priority in immutable attachment | 89 | + | // 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 only | 96 | + | 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 bound | 99 | + | // freezeRoleEffectGates establishes transition priority in immutable attachment |
| 116 | - | // for ordered acquisition and drain. Acquisition is all-or-nothing: if the | 100 | + | // order, then drains earlier admissions. |
| 117 | - | // bound expires behind another owner, only gates acquired by this call are | 101 | + | func freezeRoleEffectGates(clients ...*attachedClient) frozenRoleEffectGates { |
| 118 | - | // rolled back, in reverse order. Once the complete set is owned, a terminal | 102 | + | 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 = true | 120 | frozen.acquired = true | ||
| 137 | - | frozen.drained = drainFrozenRoleEffects(ordered, interruptByClient, frozen.interruptions, done) | 121 | + | frozen.drained = drainFrozenRoleEffects(ordered, interruptByClient, frozen.interruptions, options.done) |
| 138 | return frozen | 122 | 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 { | |||
| 82 | 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 | 94 | // roleEffectTicket reserves the exact published capability until End. End is | |
| 86 | 95 | // idempotent so error paths can safely defer it before doing any observable I/O. | |
| 87 | 96 | 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 is | 94 | // 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 ( | |||
| 16 | 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 | 27 | type blockedRenderReplacementTransport struct { | |
| 20 | 28 | sendEntered chan struct{} | |
| 21 | 29 | closed chan struct{} | |
| @@ -824,11 +832,13 @@ func TestJumpAttentionDoesNotMutateSourceAfterInitiatorReplacement(t *testing.T) | |||
| 824 | 832 | ||
| 825 | 833 | admissionEnded := make(chan struct{}) | |
| 826 | 834 | releaseAction := make(chan struct{}) | |
| 835 | + | release := releaseTestGate(t, releaseAction) | |
| 836 | + | var admissionEndedOnce sync.Once | |
| 827 | 837 | d.afterActionRoleEffectEnded = func(action string) { | |
| 828 | 838 | if action != "jump-attention" { | |
| 829 | 839 | return | |
| 830 | 840 | } | |
| 831 | - | close(admissionEnded) | |
| 841 | + | admissionEndedOnce.Do(func() { close(admissionEnded) }) | |
| 832 | 842 | <-releaseAction | |
| 833 | 843 | } | |
| 834 | 844 | actionDone := make(chan struct{}) | |
| @@ -848,7 +858,7 @@ func TestJumpAttentionDoesNotMutateSourceAfterInitiatorReplacement(t *testing.T) | |||
| 848 | 858 | sess.mu.Lock() | |
| 849 | 859 | sess.active = 0 | |
| 850 | 860 | sess.mu.Unlock() | |
| 851 | - | close(releaseAction) | |
| 861 | + | release() | |
| 852 | 862 | <-actionDone | |
| 853 | 863 | ||
| 854 | 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 | 917 | admissionEnded := make(chan struct{}) | |
| 908 | 918 | releaseAction := make(chan struct{}) | |
| 919 | + | release := releaseTestGate(t, releaseAction) | |
| 920 | + | var admissionEndedOnce sync.Once | |
| 909 | 921 | d.afterActionRoleEffectEnded = func(action string) { | |
| 910 | 922 | if action == "jump-attention" { | |
| 911 | - | close(admissionEnded) | |
| 923 | + | admissionEndedOnce.Do(func() { close(admissionEnded) }) | |
| 912 | 924 | <-releaseAction | |
| 913 | 925 | } | |
| 914 | 926 | } | |
| @@ -926,7 +938,7 @@ func TestJumpAttentionHandoffRevalidatesInitiatorAfterAdmissionEnds(t *testing.T | |||
| 926 | 938 | expectedTransport: next.transportSnapshot(), ready: true, | |
| 927 | 939 | }) | |
| 928 | 940 | require.NoError(t, err) | |
| 929 | - | close(releaseAction) | |
| 941 | + | release() | |
| 930 | 942 | <-actionDone | |
| 931 | 943 | ||
| 932 | 944 | require.Equal(t, 0, activeTabIndex(target), "stale handoff changed target focus") | |
| @@ -951,11 +963,13 @@ func TestPickerDeleteDoesNotDeleteSourceAfterInitiatorReplacement(t *testing.T) | |||
| 951 | 963 | ||
| 952 | 964 | admissionEnded := make(chan struct{}) | |
| 953 | 965 | releaseAction := make(chan struct{}) | |
| 966 | + | release := releaseTestGate(t, releaseAction) | |
| 967 | + | var admissionEndedOnce sync.Once | |
| 954 | 968 | d.afterActionRoleEffectEnded = func(action string) { | |
| 955 | 969 | if action != "picker-delete" { | |
| 956 | 970 | return | |
| 957 | 971 | } | |
| 958 | - | close(admissionEnded) | |
| 972 | + | admissionEndedOnce.Do(func() { close(admissionEnded) }) | |
| 959 | 973 | <-releaseAction | |
| 960 | 974 | } | |
| 961 | 975 | actionDone := make(chan struct{}) | |
| @@ -972,7 +986,7 @@ func TestPickerDeleteDoesNotDeleteSourceAfterInitiatorReplacement(t *testing.T) | |||
| 972 | 986 | expectedTransport: next.transportSnapshot(), ready: true, | |
| 973 | 987 | }) | |
| 974 | 988 | require.NoError(t, err) | |
| 975 | - | close(releaseAction) | |
| 989 | + | release() | |
| 976 | 990 | <-actionDone | |
| 977 | 991 | ||
| 978 | 992 | d.mu.Lock() | |
| @@ -1010,9 +1024,11 @@ func TestPickerDeleteOtherSessionRevalidatesInitiatorAfterAdmissionEnds(t *testi | |||
| 1010 | 1024 | ||
| 1011 | 1025 | admissionEnded := make(chan struct{}) | |
| 1012 | 1026 | releaseAction := make(chan struct{}) | |
| 1027 | + | release := releaseTestGate(t, releaseAction) | |
| 1028 | + | var admissionEndedOnce sync.Once | |
| 1013 | 1029 | d.afterActionRoleEffectEnded = func(action string) { | |
| 1014 | 1030 | if action == "picker-delete" { | |
| 1015 | - | close(admissionEnded) | |
| 1031 | + | admissionEndedOnce.Do(func() { close(admissionEnded) }) | |
| 1016 | 1032 | <-releaseAction | |
| 1017 | 1033 | } | |
| 1018 | 1034 | } | |
| @@ -1035,7 +1051,7 @@ func TestPickerDeleteOtherSessionRevalidatesInitiatorAfterAdmissionEnds(t *testi | |||
| 1035 | 1051 | expectedTransport: next.transportSnapshot(), ready: true, | |
| 1036 | 1052 | }) | |
| 1037 | 1053 | require.NoError(t, err) | |
| 1038 | - | close(releaseAction) | |
| 1054 | + | release() | |
| 1039 | 1055 | <-actionDone | |
| 1040 | 1056 | d.mu.Lock() | |
| 1041 | 1057 | _, targetStillRegistered = d.sessions[target.id] | |
| @@ -1191,7 +1207,10 @@ func TestRoleEffectGateAdmittedSnatchedPanelFinishesBeforePromotion(t *testing.T | |||
| 1191 | 1207 | close(release) | |
| 1192 | 1208 | <-effectDone | |
| 1193 | 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 | 1216 | 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 | return | 839 | return | ||
| 830 | } | 840 | } | ||
| 831 | - | close(admissionEnded) | 841 | + | admissionEndedOnce.Do(func() { close(admissionEnded) }) |
| 832 | <-releaseAction | 842 | <-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 = 0 | 859 | sess.active = 0 | ||
| 850 | sess.mu.Unlock() | 860 | sess.mu.Unlock() | ||
| 851 | - | close(releaseAction) | 861 | + | release() |
| 852 | <-actionDone | 862 | <-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 | <-releaseAction | 924 | <-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 | <-actionDone | 942 | <-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 | return | 970 | return | ||
| 957 | } | 971 | } | ||
| 958 | - | close(admissionEnded) | 972 | + | admissionEndedOnce.Do(func() { close(admissionEnded) }) |
| 959 | <-releaseAction | 973 | <-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 | <-actionDone | 990 | <-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 | <-releaseAction | 1032 | <-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 | <-actionDone | 1055 | <-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 | <-effectDone | 1208 | <-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 | |||
| 265 | 265 | } | |
| 266 | 266 | tb := newTabWithStableID(tabStableID, paneStableID, pty, tbSize) | |
| 267 | 267 | if !lifetime.publish(tb.focusedPane()) { | |
| 268 | + | lifetime.abort() | |
| 268 | 269 | _ = pty.Close() | |
| 269 | 270 | closeTabs(tabs) | |
| 270 | 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 | 408 | if err != nil { | |
| 408 | 409 | _ = d.killSession(newSess, ports.ReasonSessionKilled, true) | |
| 409 | - | return errors.New("client detached") | |
| 410 | + | return err | |
| 410 | 411 | } | |
| 411 | 412 | ||
| 412 | 413 | d.touchMRU(newSess) | |
| @@ -517,6 +518,7 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error { | |||
| 517 | 518 | if !lifetime.publish(p) { | |
| 518 | 519 | sess.mu.Unlock() | |
| 519 | 520 | d.mu.Unlock() | |
| 521 | + | lifetime.abort() | |
| 520 | 522 | _ = pty.Close() | |
| 521 | 523 | return errors.New("daemon: session closed") | |
| 522 | 524 | } | |
| @@ -706,7 +708,7 @@ func (d *Daemon) detachIfRoleCurrentUntil(token attachmentRoleToken, done func() | |||
| 706 | 708 | if token.sess == nil || token.ac == nil || token.role != attachmentActive || token.transport.transport == nil { | |
| 707 | 709 | return false | |
| 708 | 710 | } | |
| 709 | - | frozen := freezeRoleEffectGatesInterruptingObservedUntil(nil, done, nil, token.ac) | |
| 711 | + | frozen := freezeRoleEffectGatesWith(roleEffectFreezeOptions{done: done}, token.ac) | |
| 710 | 712 | defer frozen.unfreeze() | |
| 711 | 713 | if !frozen.acquired || !frozen.drained { | |
| 712 | 714 | return false | |
| @@ -1331,11 +1333,11 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu | |||
| 1331 | 1333 | drainDone = drainDeadline.Done | |
| 1332 | 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 | 1337 | if d.afterRoleEffectGateFrozen != nil { | |
| 1336 | 1338 | d.afterRoleEffectGateFrozen(action, ac) | |
| 1337 | 1339 | } | |
| 1338 | - | }, participants.roleGates...) | |
| 1340 | + | }}, participants.roleGates...) | |
| 1339 | 1341 | defer func() { frozen.unfreeze() }() | |
| 1340 | 1342 | if !frozen.acquired { | |
| 1341 | 1343 | // 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 | 1492 | stoppedName := sess.name | |
| 1491 | 1493 | stoppedCwd := sess.cwd | |
| 1492 | 1494 | createdAt := sess.createdAt | |
| 1493 | - | tabNames := sess.persistRecordLocked(time.Now().UnixNano()).TabNames | |
| 1495 | + | tabNames := sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))).TabNames | |
| 1494 | 1496 | ephemeral := sess.ephemeral | |
| 1495 | 1497 | unlockCoordinators() | |
| 1496 | 1498 | 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 false | 709 | 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 false | 714 | return false | ||
| @@ -1331,11 +1333,11 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu | |||||
| 1331 | drainDone = drainDeadline.Done | 1333 | 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 it | 1343 | // 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.name | 1492 | stoppedName := sess.name | ||
| 1491 | stoppedCwd := sess.cwd | 1493 | stoppedCwd := sess.cwd | ||
| 1492 | createdAt := sess.createdAt | 1494 | createdAt := sess.createdAt | ||
| 1493 | - | tabNames := sess.persistRecordLocked(time.Now().UnixNano()).TabNames | 1495 | + | tabNames := sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))).TabNames |
| 1494 | ephemeral := sess.ephemeral | 1496 | 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) { | |||
| 41 | 41 | locator := sessionMoveLocator(sess) | |
| 42 | 42 | require.Equal(t, sess.id, locator.ID) | |
| 43 | 43 | require.Equal(t, sess.incarnation, locator.Incarnation) | |
| 44 | + | require.Equal(t, "session-9999", locator.Name) | |
| 44 | 45 | } | |
| @@ -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) { | |||
| 304 | 304 | trB, sendsB, releaseB := newConn(t, mustHello(ports.IntentAttach, "0", domain.Size{Cols: 80, Rows: 24})) | |
| 305 | 305 | hg.Go(func() { d.handleConn(trB) }) | |
| 306 | 306 | awaitFrame(t, sendsB, ports.MsgWelcome) | |
| 307 | + | d.attachmentCleanupWg.Wait() | |
| 307 | 308 | ||
| 308 | 309 | // B is now the sole active client while A remains a live snatched member. | |
| 309 | 310 | 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) { | |||
| 215 | 215 | func TestBlockedSnatchedQuitDeadlineRetiresOnlyExactWaiter(t *testing.T) { | |
| 216 | 216 | clock := &signalClock{timers: make(chan *signalTimer, 2)} | |
| 217 | 217 | waitingTransport := newBlockedSnatchedQuitTransport() | |
| 218 | - | defer waitingTransport.Close() | |
| 218 | + | defer func() { _ = waitingTransport.Close() }() | |
| 219 | 219 | activeTransport := &closeTrackingTransport{} | |
| 220 | 220 | d, sess, waiting, active := newAtomicReclaimFixture(t, clock, waitingTransport, activeTransport) | |
| 221 | 221 | ||
| @@ -275,7 +275,7 @@ func TestBlockedSnatchedQuitDeadlineRetiresOnlyExactWaiter(t *testing.T) { | |||
| 275 | 275 | func TestBlockedSnatchedQuitCleanupRejectsReclaimedTransportGeneration(t *testing.T) { | |
| 276 | 276 | clock := &signalClock{timers: make(chan *signalTimer, 2)} | |
| 277 | 277 | staleTransport := newBlockedSnatchedQuitTransport() | |
| 278 | - | defer staleTransport.Close() | |
| 278 | + | defer func() { _ = staleTransport.Close() }() | |
| 279 | 279 | ownerTransport := &closeTrackingTransport{} | |
| 280 | 280 | d, sess, waiting, owner := newAtomicReclaimFixture(t, clock, staleTransport, ownerTransport) | |
| 281 | 281 | ||
| @@ -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) { | |||
| 143 | 143 | ||
| 144 | 144 | d.attachmentCleanupWg.Wait() | |
| 145 | 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 | 149 | require.Equal(t, domain.Size{Cols: 80, Rows: 24}, requester.size) | |
| 148 | 150 | } | |
| 149 | 151 | ||
| @@ -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) { | |||
| 146 | 146 | timer.ch <- time.Time{} | |
| 147 | 147 | ||
| 148 | 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 | 150 | require.True(t, old.Closed(), "timed-out captured transport was not closed") | |
| 151 | 151 | require.False(t, fresh.Closed(), "replacement transport must remain open") | |
| 152 | 152 | ||
| @@ -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) { | |||
| 528 | 528 | } | |
| 529 | 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 | 534 | require.Equal(t, tc.want, ac.getAppliedTheme().Raw) | |
| 534 | 535 | assertSessionDefaultColors(t, sess, tc.want.Foreground, tc.want.Background) | |
| @@ -717,7 +718,8 @@ func TestClientGoneResetDoesNotClobberNewlyAttachedClient(t *testing.T) { | |||
| 717 | 718 | require.True(t, sess.detachIfCurrent(ac)) | |
| 718 | 719 | ||
| 719 | 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 | 723 | d.applyTheme(sess, newAC, ports.Theme{ | |
| 722 | 724 | HasForeground: true, Foreground: renderer.RGB{R: 20, G: 21, B: 22}, | |
| 723 | 725 | 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) { | |||
| 88 | 88 | p := member.pane | |
| 89 | 89 | p.resizeMu.Lock() | |
| 90 | 90 | p.mu.Lock() | |
| 91 | - | if !resizeMemberOwnerCurrentLocked(member) { | |
| 91 | + | if !resizeMemberOwnerCurrent(member) { | |
| 92 | 92 | member.retry = p.owner.Load() != nil | |
| 93 | 93 | p.resizeRetry = member.retry | |
| 94 | 94 | p.mu.Unlock() | |
| @@ -111,7 +111,7 @@ func (d *Daemon) applyPreparedTabMembers(plan *preparedTabLayout) { | |||
| 111 | 111 | } else if err := pty.Resize(rectSize(member.rect)); err != nil { | |
| 112 | 112 | d.log.Warn("pty resize failed", "err", err) | |
| 113 | 113 | p.mu.Lock() | |
| 114 | - | current := resizeMemberOwnerCurrentLocked(member) | |
| 114 | + | current := resizeMemberOwnerCurrent(member) | |
| 115 | 115 | p.resizeRetry = true | |
| 116 | 116 | p.mu.Unlock() | |
| 117 | 117 | d.replayResizePending(member.session, member.tab, p, false, member.rect) | |
| @@ -121,7 +121,7 @@ func (d *Daemon) applyPreparedTabMembers(plan *preparedTabLayout) { | |||
| 121 | 121 | } | |
| 122 | 122 | } else { | |
| 123 | 123 | p.mu.Lock() | |
| 124 | - | current := resizeMemberOwnerCurrentLocked(member) | |
| 124 | + | current := resizeMemberOwnerCurrent(member) | |
| 125 | 125 | if !current && p.owner.Load() != nil { | |
| 126 | 126 | p.resizeRetry = true | |
| 127 | 127 | 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 | 138 | if member == nil || member.pane == nil { | |
| 139 | 139 | return false | |
| 140 | 140 | } | |
| @@ -154,7 +154,7 @@ func validatePreparedTabLayoutLocked(tb *tab, plan *preparedTabLayout) bool { | |||
| 154 | 154 | } | |
| 155 | 155 | for i := range plan.members { | |
| 156 | 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 | 158 | return false | |
| 159 | 159 | } | |
| 160 | 160 | } | |
| @@ -163,7 +163,7 @@ func validatePreparedTabLayoutLocked(tb *tab, plan *preparedTabLayout) bool { | |||
| 163 | 163 | ||
| 164 | 164 | func resizeMembersOwnerCurrent(members []resizeMember) bool { | |
| 165 | 165 | for i := range members { | |
| 166 | - | if !resizeMemberOwnerCurrentLocked(&members[i]) { | |
| 166 | + | if !resizeMemberOwnerCurrent(&members[i]) { | |
| 167 | 167 | return false | |
| 168 | 168 | } | |
| 169 | 169 | } | |
| @@ -201,12 +201,19 @@ func commitPreparedTabLayoutLocked(plan *preparedTabLayout) bool { | |||
| 201 | 201 | for i := range plan.members { | |
| 202 | 202 | member := &plan.members[i] | |
| 203 | 203 | member.pane.mu.Lock() | |
| 204 | - | if !resizeMemberOwnerCurrentLocked(member) { | |
| 204 | + | current := resizeMemberOwnerCurrent(member) | |
| 205 | + | if !current { | |
| 205 | 206 | member.pane.resizeRetry = member.pane.owner.Load() != nil | |
| 206 | 207 | member.retry = member.pane.resizeRetry | |
| 207 | - | member.pane.mu.Unlock() | |
| 208 | + | } | |
| 209 | + | member.pane.mu.Unlock() | |
| 210 | + | if !current { | |
| 208 | 211 | return false | |
| 209 | 212 | } | |
| 213 | + | } | |
| 214 | + | for i := range plan.members { | |
| 215 | + | member := &plan.members[i] | |
| 216 | + | member.pane.mu.Lock() | |
| 210 | 217 | member.pane.rect = member.rect | |
| 211 | 218 | if member.ok { | |
| 212 | 219 | member.pane.resizeRetry = false | |
| @@ -424,13 +431,13 @@ func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, cur | |||
| 424 | 431 | // relaunched, or resized slot must never receive this attempt's geometry. | |
| 425 | 432 | tb.mu.Lock() | |
| 426 | 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 | 435 | if current != nil && !current() { | |
| 429 | 436 | currentSlot = false | |
| 430 | 437 | } | |
| 431 | 438 | if currentSlot { | |
| 432 | 439 | p.mu.Lock() | |
| 433 | - | currentSlot = resizeMemberOwnerCurrentLocked(&plan.members[0]) | |
| 440 | + | currentSlot = resizeMemberOwnerCurrent(&plan.members[0]) | |
| 434 | 441 | if currentSlot { | |
| 435 | 442 | p.rect = geometry.Inner | |
| 436 | 443 | p.popupGeometry = geometry | |
| @@ -443,7 +450,7 @@ func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, cur | |||
| 443 | 450 | } | |
| 444 | 451 | tb.mu.Unlock() | |
| 445 | 452 | if !currentSlot { | |
| 446 | - | ownerCurrent := resizeMemberOwnerCurrentLocked(&plan.members[0]) | |
| 453 | + | ownerCurrent := resizeMemberOwnerCurrent(&plan.members[0]) | |
| 447 | 454 | // This attempt has no tiled transaction loop to retain the gate for, so | |
| 448 | 455 | // discard its buffered bytes at the old screen size before returning. | |
| 449 | 456 | for i := range plan.members { | |
| @@ -605,11 +612,12 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad | |||
| 605 | 612 | members = append(members, floatingResult.members...) | |
| 606 | 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 | 617 | d.notify(sess, domain.NoticeWarn, domain.NoticeResizeFailed, | |
| 610 | 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 | 621 | return resizeApplyResult{}, false | |
| 614 | 622 | } | |
| 615 | 623 | return resizeApplyResult{members: members, failed: failed}, true | |
| @@ -88,7 +88,7 @@ func (d *Daemon) applyPreparedTabMembers(plan *preparedTabLayout) { | |||||
| 88 | p := member.pane | 88 | 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() != nil | 92 | member.retry = p.owner.Load() != nil | ||
| 93 | p.resizeRetry = member.retry | 93 | 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 = true | 115 | 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 = true | 126 | p.resizeRetry = true | ||
| 127 | member.retry = true | 127 | 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 false | 139 | 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 false | 158 | 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 false | 167 | 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() != nil | 206 | member.pane.resizeRetry = member.pane.owner.Load() != nil | ||
| 206 | member.retry = member.pane.resizeRetry | 207 | member.retry = member.pane.resizeRetry | ||
| 207 | - | member.pane.mu.Unlock() | 208 | + | } |
| 209 | + | member.pane.mu.Unlock() | |||
| 210 | + | if !current { | |||
| 208 | return false | 211 | 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.rect | 217 | member.pane.rect = member.rect | ||
| 211 | if member.ok { | 218 | if member.ok { | ||
| 212 | member.pane.resizeRetry = false | 219 | 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 = false | 436 | 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.Inner | 442 | p.rect = geometry.Inner | ||
| 436 | p.popupGeometry = geometry | 443 | 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, so | 454 | // 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{}, false | 621 | return resizeApplyResult{}, false | ||
| 614 | } | 622 | } | ||
| 615 | return resizeApplyResult{members: members, failed: failed}, true | 623 | 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) { | |||
| 188 | 188 | // DSR is flushed back to the child and OSC 52 is forwarded through the | |
| 189 | 189 | // normal asynchronous client path, proving replay uses processPTYData. | |
| 190 | 190 | var clipboardOutput string | |
| 191 | - | for range 3 { | |
| 191 | + | for { | |
| 192 | 192 | frame := awaitFrame(t, sends, ports.MsgOutput) | |
| 193 | 193 | out, err := ports.UnmarshalOutput(frame.Payload) | |
| 194 | 194 | 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 the | 188 | // 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 string | 190 | 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) { | |||
| 89 | 89 | h = r.h | |
| 90 | 90 | } | |
| 91 | 91 | r.mu.Lock() | |
| 92 | - | defer r.mu.Unlock() | |
| 92 | + | var pendingAlt []byte | |
| 93 | 93 | 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 { | |
| 96 | 99 | r.stopTimer() | |
| 97 | 100 | r.pending = false | |
| 98 | 101 | r.pendingAlt = nil | |
| 99 | 102 | r.pendingHandler = nil | |
| 103 | + | } | |
| 104 | + | r.mu.Unlock() | |
| 105 | + | if wasPending { | |
| 100 | 106 | combined := append(append([]byte(nil), pendingAlt...), data...) | |
| 101 | 107 | if consumed := r.routeAfterPendingESC(combined, len(pendingAlt), pendingHandler); consumed > len(pendingAlt) { | |
| 102 | 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 | 217 | func (r *Router) retainESC(h Handler, altBytes ...[]byte) { | |
| 218 | + | r.mu.Lock() | |
| 212 | 219 | r.pending = true | |
| 213 | 220 | r.pendingAlt = nil | |
| 214 | 221 | r.pendingHandler = h | |
| @@ -217,23 +224,33 @@ func (r *Router) retainESC(h Handler, altBytes ...[]byte) { | |||
| 217 | 224 | } | |
| 218 | 225 | r.timer = r.clock.NewTimer(r.delay) | |
| 219 | 226 | r.pendingDone = make(chan struct{}) | |
| 227 | + | timer, done := r.timer, r.pendingDone | |
| 228 | + | r.mu.Unlock() | |
| 220 | 229 | go func(timer ports.Timer, done <-chan struct{}) { | |
| 221 | 230 | select { | |
| 222 | 231 | case <-timer.C(): | |
| 223 | 232 | case <-done: | |
| 224 | 233 | return | |
| 225 | 234 | } | |
| 235 | + | var ( | |
| 236 | + | data []byte | |
| 237 | + | h Handler | |
| 238 | + | ) | |
| 226 | 239 | r.mu.Lock() | |
| 227 | - | defer r.mu.Unlock() | |
| 228 | 240 | if r.pending && r.timer == timer { | |
| 229 | 241 | 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 | |
| 232 | 244 | r.pendingAlt = nil | |
| 233 | 245 | r.pendingHandler = nil | |
| 246 | + | r.timer = nil | |
| 247 | + | r.pendingDone = nil | |
| 248 | + | } | |
| 249 | + | r.mu.Unlock() | |
| 250 | + | if h != nil { | |
| 234 | 251 | r.forward(data, h) | |
| 235 | 252 | } | |
| 236 | - | }(r.timer, r.pendingDone) | |
| 253 | + | }(timer, done) | |
| 237 | 254 | } | |
| 238 | 255 | ||
| 239 | 256 | func (r *Router) stopTimer() { | |
| @@ -89,14 +89,20 @@ func (r *Router) RouteWithHandler(data []byte, h Handler) { | |||||
| 89 | h = r.h | 89 | 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.pendingAlt | 94 | + | pendingAlt = append([]byte(nil), r.pendingAlt...) |
| 95 | - | pendingHandler := r.pendingHandler | 95 | + | } |
| 96 | + | pendingHandler := r.pendingHandler | |||
| 97 | + | wasPending := r.pending | |||
| 98 | + | if wasPending { | |||
| 96 | r.stopTimer() | 99 | r.stopTimer() | ||
| 97 | r.pending = false | 100 | r.pending = false | ||
| 98 | r.pendingAlt = nil | 101 | r.pendingAlt = nil | ||
| 99 | r.pendingHandler = nil | 102 | 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 = true | 219 | r.pending = true | ||
| 213 | r.pendingAlt = nil | 220 | r.pendingAlt = nil | ||
| 214 | r.pendingHandler = h | 221 | 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 | return | 233 | 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 = false | 241 | r.pending = false | ||
| 230 | - | data := append([]byte{ESC}, r.pendingAlt...) | 242 | + | data = append([]byte{ESC}, r.pendingAlt...) |
| 231 | - | h := r.pendingHandler | 243 | + | h = r.pendingHandler |
| 232 | r.pendingAlt = nil | 244 | r.pendingAlt = nil | ||
| 233 | r.pendingHandler = nil | 245 | 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() { | ||