detached 4ef812d

feat(client): reuse parked remote routes

commit
4ef812d9ef657e4357250cbfca97d868b8473243
author
brice · · 2026-08-25 13:35 UTC
parent
9bd6185
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +1080 -86
M internal/usecase/client/ack_queue_test.go +44 -2
@@ -32,7 +32,7 @@ func TestCumulativeAckBypassesFullNormalSendQueue(t *testing.T) {
3232 defer cancel()
3333 tr := &ackRecordingTransport{sent: make(chan ports.Frame, sendQueueDepth+1)}
3434 errCh := make(chan error, 1)
35- go runSender(ctx, cancel, tr, nil, normal, nil, acks, errCh, slog.Default())
35+ go runSender(ctx, cancel, tr, nil, nil, normal, nil, acks, errCh, slog.Default())
3636
3737 first := <-tr.sent
3838 require.Equal(t, ports.MsgAck, first.Type)
@@ -54,7 +54,7 @@ func TestSamePeerSwitchControlBypassesHeldInput(t *testing.T) {
5454 transport := &ackRecordingTransport{sent: make(chan ports.Frame, 2)}
5555 acks := newCumulativeAckQueue()
5656 errs := make(chan error, 1)
57- go runSender(ctx, cancel, transport, control, normal, gate, acks, errs, slog.Default())
57+ go runSender(ctx, cancel, transport, control, nil, normal, gate, acks, errs, slog.Default())
5858
5959 normal <- ports.Frame{Type: ports.MsgInput, Payload: []byte("held")}
6060 awaitSenderSignal(t, held)
@@ -73,6 +73,48 @@ func TestSamePeerSwitchControlBypassesHeldInput(t *testing.T) {
7373 }
7474 }
7575
76+func TestSenderBarrierLeavesPausedInputQueued(t *testing.T) {
77+ ctx, cancel := context.WithCancel(context.Background())
78+ defer cancel()
79+ normal := make(chan ports.Frame, 1)
80+ barriers := make(chan chan struct{})
81+ gate := newSamePeerInputGate()
82+ gate.setPaused(true)
83+ transport := &ackRecordingTransport{sent: make(chan ports.Frame, 1)}
84+ go runSender(ctx, cancel, transport, nil, barriers, normal, gate, newCumulativeAckQueue(), make(chan error, 1), slog.Default())
85+
86+ normal <- ports.Frame{Type: ports.MsgInput, Payload: []byte("held")}
87+ barrierDone := make(chan struct{})
88+ barriers <- barrierDone
89+ awaitSenderSignal(t, barrierDone)
90+ select {
91+ case frame := <-transport.sent:
92+ t.Fatalf("barrier sent paused input frame %d", frame.Type)
93+ default:
94+ }
95+
96+ gate.setPaused(false)
97+ if got := awaitSenderFrame(t, transport.sent); got.Type != ports.MsgInput {
98+ t.Fatalf("released frame = %d, want input", got.Type)
99+ }
100+}
101+
102+func TestSenderBarrierFlushesPendingAck(t *testing.T) {
103+ ctx, cancel := context.WithCancel(context.Background())
104+ defer cancel()
105+ acks := newCumulativeAckQueue()
106+ acks.offer(3, 9)
107+ transport := &ackRecordingTransport{sent: make(chan ports.Frame, 1)}
108+ barriers := make(chan chan struct{})
109+ go runSender(ctx, cancel, transport, nil, barriers, make(chan ports.Frame, 1), newSamePeerInputGate(), acks, make(chan error, 1), slog.Default())
110+
111+ done := make(chan struct{})
112+ barriers <- done
113+ frame := awaitSenderFrame(t, transport.sent)
114+ require.Equal(t, ports.MsgAck, frame.Type)
115+ awaitSenderSignal(t, done)
116+}
117+
76118 func awaitSenderSignal(t *testing.T, signal <-chan struct{}) {
77119 t.Helper()
78120 select {
@@ -32,7 +32,7 @@ func TestCumulativeAckBypassesFullNormalSendQueue(t *testing.T) {
32 defer cancel()32 defer cancel()
33 tr := &ackRecordingTransport{sent: make(chan ports.Frame, sendQueueDepth+1)}33 tr := &ackRecordingTransport{sent: make(chan ports.Frame, sendQueueDepth+1)}
34 errCh := make(chan error, 1)34 errCh := make(chan error, 1)
35- go runSender(ctx, cancel, tr, nil, normal, nil, acks, errCh, slog.Default())35+ go runSender(ctx, cancel, tr, nil, nil, normal, nil, acks, errCh, slog.Default())
36 36
37 first := <-tr.sent37 first := <-tr.sent
38 require.Equal(t, ports.MsgAck, first.Type)38 require.Equal(t, ports.MsgAck, first.Type)
@@ -54,7 +54,7 @@ func TestSamePeerSwitchControlBypassesHeldInput(t *testing.T) {
54 transport := &ackRecordingTransport{sent: make(chan ports.Frame, 2)}54 transport := &ackRecordingTransport{sent: make(chan ports.Frame, 2)}
55 acks := newCumulativeAckQueue()55 acks := newCumulativeAckQueue()
56 errs := make(chan error, 1)56 errs := make(chan error, 1)
57- go runSender(ctx, cancel, transport, control, normal, gate, acks, errs, slog.Default())57+ go runSender(ctx, cancel, transport, control, nil, normal, gate, acks, errs, slog.Default())
58 58
59 normal <- ports.Frame{Type: ports.MsgInput, Payload: []byte("held")}59 normal <- ports.Frame{Type: ports.MsgInput, Payload: []byte("held")}
60 awaitSenderSignal(t, held)60 awaitSenderSignal(t, held)
@@ -73,6 +73,48 @@ func TestSamePeerSwitchControlBypassesHeldInput(t *testing.T) {
73 }73 }
74 }74 }
75 75
76+func TestSenderBarrierLeavesPausedInputQueued(t *testing.T) {
77+ ctx, cancel := context.WithCancel(context.Background())
78+ defer cancel()
79+ normal := make(chan ports.Frame, 1)
80+ barriers := make(chan chan struct{})
81+ gate := newSamePeerInputGate()
82+ gate.setPaused(true)
83+ transport := &ackRecordingTransport{sent: make(chan ports.Frame, 1)}
84+ go runSender(ctx, cancel, transport, nil, barriers, normal, gate, newCumulativeAckQueue(), make(chan error, 1), slog.Default())
85+
86+ normal <- ports.Frame{Type: ports.MsgInput, Payload: []byte("held")}
87+ barrierDone := make(chan struct{})
88+ barriers <- barrierDone
89+ awaitSenderSignal(t, barrierDone)
90+ select {
91+ case frame := <-transport.sent:
92+ t.Fatalf("barrier sent paused input frame %d", frame.Type)
93+ default:
94+ }
95+
96+ gate.setPaused(false)
97+ if got := awaitSenderFrame(t, transport.sent); got.Type != ports.MsgInput {
98+ t.Fatalf("released frame = %d, want input", got.Type)
99+ }
100+}
101+
102+func TestSenderBarrierFlushesPendingAck(t *testing.T) {
103+ ctx, cancel := context.WithCancel(context.Background())
104+ defer cancel()
105+ acks := newCumulativeAckQueue()
106+ acks.offer(3, 9)
107+ transport := &ackRecordingTransport{sent: make(chan ports.Frame, 1)}
108+ barriers := make(chan chan struct{})
109+ go runSender(ctx, cancel, transport, nil, barriers, make(chan ports.Frame, 1), newSamePeerInputGate(), acks, make(chan error, 1), slog.Default())
110+
111+ done := make(chan struct{})
112+ barriers <- done
113+ frame := awaitSenderFrame(t, transport.sent)
114+ require.Equal(t, ports.MsgAck, frame.Type)
115+ awaitSenderSignal(t, done)
116+}
117+
76 func awaitSenderSignal(t *testing.T, signal <-chan struct{}) {118 func awaitSenderSignal(t *testing.T, signal <-chan struct{}) {
77 t.Helper()119 t.Helper()
78 select {120 select {
M internal/usecase/client/client.go +494 -72
@@ -398,6 +398,8 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
398398 rawEntered: &rawEntered,
399399 stage: reconnectStageOfflineRetrying,
400400 }
401+ transition := newTransitionUI(r.term, r.clock, &rawEntered)
402+ defer transition.stop()
401403 restoreReturnRoute := func(route attachRoute) {
402404 returnNavigationPending = true
403405 dialer = route.dialer
@@ -444,12 +446,58 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
444446 return nil
445447 }
446448
449+ runTransientHomePicker := func(attemptCtx context.Context) attachResult {
450+ if homeRoute == nil {
451+ return attachResult{err: errors.New("vev: home route unavailable")}
452+ }
453+ request := cloneAttachRequest(homeRoute.request)
454+ request.Intent = ports.IntentAttach
455+ request.NavigationCapabilities = ports.NavigationCapabilityBack
456+ request.StartupOverlay = ports.StartupOverlaySessionPicker
457+ request.RemoteTarget = nil
458+ request.EnvironmentPolicy = ports.EnvironmentPolicyClientOwned
459+ request.Remote = false
460+ if err := validateAttachRequest(request); err != nil {
461+ return attachResult{err: err}
462+ }
463+ handshakeCtx, timedOut, finishHandshake := newHandshakeContext(attemptCtx, r.clock)
464+ transport, err := boundedDial(handshakeCtx, homeRoute.dialer)
465+ if err != nil {
466+ err = handshakeContextError(attemptCtx, timedOut, err)
467+ finishHandshake()
468+ return attachResult{err: err}
469+ }
470+ connection, err := NewSessionConnection(transport, SessionTarget{Intent: request.Intent, SessionName: request.SessionName})
471+ if err != nil {
472+ finishHandshake()
473+ _ = transport.Close()
474+ return attachResult{err: err}
475+ }
476+ stopHandshakeTransport := watchHandshakeTransport(handshakeCtx, transport)
477+ localReconnect := &reconnectUI{term: r.term, rawEntered: &rawEntered}
478+ result := (&attachAttempt{
479+ runner: r, dialer: homeRoute.dialer, connection: connection,
480+ handshakeCtx: handshakeCtx, handshakeTimedOut: timedOut,
481+ finishHandshake: finishHandshake, stopHandshakeTransport: stopHandshakeTransport,
482+ request: request, clientID: processClientID, milestones: &ms,
483+ themeState: themeState, enterRaw: enterRaw, reconnect: localReconnect,
484+ terminalInput: func() *terminalInputPump { return input },
485+ transientPicker: true, returnAttachTargets: true,
486+ }).run(attemptCtx)
487+ stopHandshakeTransport()
488+ finishHandshake()
489+ if !result.transportClosed {
490+ _ = connection.Close()
491+ }
492+ return result
493+ }
494+
447495 for {
448496 if err := validateAttachRequest(attemptRequest); err != nil {
449497 return err
450498 }
451499 handshakeCtx, timedOut, finishHandshake := newHandshakeContext(ctx, r.clock)
452- transport, err := boundedDial(handshakeCtx, dialer)
500+ transport, err := boundedDialWithTransition(handshakeCtx, dialer, transition)
453501 if err != nil {
454502 err = handshakeContextError(ctx, timedOut, err)
455503 finishHandshake()
@@ -515,11 +563,13 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
515563 themeState: themeState,
516564 enterRaw: enterRaw,
517565 reconnect: reconnect,
566+ transition: transition,
518567 linkEvents: linkEvents,
519568 rememberRemoteHost: rememberRemoteHost,
520569 terminalInput: func() *terminalInputPump {
521570 return input
522571 },
572+ openHomePicker: runTransientHomePicker,
523573 }).run(ctx)
524574 stopHandshakeTransport()
525575 finishHandshake()
@@ -655,8 +705,16 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
655705 if r.ledger == nil {
656706 return errors.New("vev: route ledger unavailable")
657707 }
708+ baseRequest := attemptRequest
658709 nextDialer = dialer
659- nextRequest = r.ledger.samePeerHandoff(attemptRequest, *result.target)
710+ if result.homePickerTarget {
711+ if homeRoute == nil {
712+ return errors.New("vev: home route unavailable for picker target")
713+ }
714+ nextDialer = homeRoute.dialer
715+ baseRequest = homeRoute.request
716+ }
717+ nextRequest = r.ledger.samePeerHandoff(baseRequest, *result.target)
660718 } else {
661719 if r.attachHandoff == nil {
662720 return &AttachTargetError{Target: *result.target}
@@ -910,9 +968,13 @@ type attachAttempt struct {
910968 themeState *terminalThemeState
911969 enterRaw func() error
912970 reconnect *reconnectUI
971+ transition *transitionUI
913972 linkEvents <-chan ports.LinkEvent
914973 rememberRemoteHost func()
915974 terminalInput func() *terminalInputPump
975+ openHomePicker func(context.Context) attachResult
976+ transientPicker bool
977+ returnAttachTargets bool
916978 }
917979
918980 type attachResult struct {
@@ -924,6 +986,7 @@ type attachResult struct {
924986 transportClosed bool
925987 target *ports.AttachTarget
926988 action ports.NavigationAction
989+ homePickerTarget bool
927990 routeAction *ports.RouteNavigationAction
928991 err error
929992 }
@@ -979,6 +1042,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
9791042 themeState := a.themeState
9801043 enterRaw := a.enterRaw
9811044 reconnect := a.reconnect
1045+ transition := a.transition
9821046 linkEvents := a.linkEvents
9831047 remote := a.remote || a.request.Remote
9841048 clipboard := a.runner.clipboard
@@ -1013,7 +1077,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
10131077 return attachResult{transportClosed: handshakeCtx.Err() != nil, err: fmt.Errorf("vev: %s: %w", stage, err)}
10141078 }
10151079 sendHandshake := func(operation func() error) error {
1016- err := boundedHandshakeOperation(handshakeCtx, transport, operation)
1080+ err := boundedHandshakeOperationWithTransition(handshakeCtx, transport, operation, transition)
10171081 if err != nil {
10181082 return handshakeContextError(ctx, handshakeTimedOut, err)
10191083 }
@@ -1067,11 +1131,11 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
10671131
10681132 // 2. Await Welcome or a typed rejection.
10691133 var reply ports.Frame
1070- if err := boundedHandshakeOperation(handshakeCtx, transport, func() error {
1134+ if err := boundedHandshakeOperationWithTransition(handshakeCtx, transport, func() error {
10711135 var recvErr error
10721136 reply, recvErr = transport.Recv()
10731137 return recvErr
1074- }); err != nil {
1138+ }, transition); err != nil {
10751139 return handshakeFailure("awaiting welcome", err)
10761140 }
10771141 var committedIdentity *ports.CommittedRouteIdentity
@@ -1122,16 +1186,18 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
11221186 if request.RemoteTarget != nil && (request.RemoteTarget.LifecycleID != committedIdentity.Target.LifecycleID || request.RemoteTarget.SessionName != committedIdentity.Target.SessionName) {
11231187 return welcomedResult(errRouteTargetChanged)
11241188 }
1125- candidate := routeCandidateForAttach(request, *committedIdentity, a.dialer, resumeToken)
1126- var commitErr error
1127- if a.routeNavigationSelection != nil {
1128- selection := *a.routeNavigationSelection
1129- commitErr = a.runner.ledger.commitTransition(selection.selected.identity, selection.snapshotGeneration, selection.active, candidate)
1130- } else {
1131- _, commitErr = a.runner.ledger.commitAttach(candidate)
1132- }
1133- if commitErr != nil {
1134- return welcomedResult(fmt.Errorf("vev: committing route identity: %w", commitErr))
1189+ if !a.transientPicker {
1190+ candidate := routeCandidateForAttach(request, *committedIdentity, a.dialer, resumeToken)
1191+ var commitErr error
1192+ if a.routeNavigationSelection != nil {
1193+ selection := *a.routeNavigationSelection
1194+ commitErr = a.runner.ledger.commitTransition(selection.selected.identity, selection.snapshotGeneration, selection.active, candidate)
1195+ } else {
1196+ _, commitErr = a.runner.ledger.commitAttach(candidate)
1197+ }
1198+ if commitErr != nil {
1199+ return welcomedResult(fmt.Errorf("vev: committing route identity: %w", commitErr))
1200+ }
11351201 }
11361202 snapshotPayload, err := ports.MarshalRecentRouteSnapshot(a.runner.ledger.snapshot())
11371203 if err != nil {
@@ -1183,21 +1249,9 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
11831249 input.start()
11841250 defer input.stop()
11851251 }
1186- inputConsumer := input.claim()
1187- claimActive := true
1188- revokeInputClaim := func() {
1189- if claimActive {
1190- input.revoke(inputConsumer)
1191- claimActive = false
1192- }
1193- }
1194- // Register revocation before any palette publication or terminal I/O: both
1195- // can fail before the stdin scanner is started, and a reconnect must still
1196- // be able to claim this lifecycle-owned reader.
1197- defer revokeInputClaim()
1198-
11991252 sendCh := make(chan ports.Frame, sendQueueDepth)
12001253 controlCh := make(chan ports.Frame, 1)
1254+ barrierCh := make(chan chan struct{})
12011255 inputGate := newSamePeerInputGate()
12021256 ackQueue := newCumulativeAckQueue()
12031257 sendErrCh := make(chan error, 1)
@@ -1309,8 +1363,21 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
13091363 awaitingReconnectReset := false
13101364 outputState := outputApplyState{}
13111365 outputResetRequested := false
1366+ transitionWaitingFull := false
13121367 var samePeerSwitch *samePeerSwitchPending
13131368 var nextSamePeerRequestID uint64
1369+ type parkedRoutePending struct {
1370+ requestID uint64
1371+ action ports.ParkedRouteAction
1372+ leaseID ports.ParkedRouteLeaseID
1373+ fallbackTarget *ports.AttachTarget
1374+ timer ports.Timer
1375+ }
1376+ var parkedPending *parkedRoutePending
1377+ var nextParkedRequestID uint64
1378+ parkedWaitingFull := false
1379+ var parkedFullTimer ports.Timer
1380+ transportFailed := make(chan struct{})
13141381 if reconnect.showing {
13151382 if reconnect.remote {
13161383 // Before the asynchronous sender starts, preserve transport ordering by
@@ -1339,27 +1406,195 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
13391406 // or unchanged screen may legitimately produce no initial Output frame.
13401407 endHandshake()
13411408 senderStarted = true
1342- go runSender(loopCtx, cancel, transport, controlCh, sendCh, inputGate, ackQueue, sendErrCh, log)
1343- stdinDone := make(chan struct{})
1344- go func() {
1345- defer close(stdinDone)
1346- (&stdinPump{ctx: loopCtx, cancel: cancel, input: input, consumer: inputConsumer, out: sendCh, clock: clk, clipboard: clip, logger: log, paletteEvents: paletteEvents, activeGeneration: &activeGeneration}).run()
1347- }()
1348- // Scanner cancellation can leave an undecided marker suffix (including a
1349- // standalone Escape) that it must hand back to the lifecycle-owned reader.
1350- // Wait for that handoff before revoking the claim: otherwise a replacement
1351- // scanner could claim the reader between revocation and preservation and
1352- // lose the suffix.
1353- defer func() {
1354- cancel()
1355- <-stdinDone
1356- revokeInputClaim()
1357- }()
1358- go runResize(loopCtx, term.ResizeEvents(), sendCh, log)
1409+ go runSender(loopCtx, cancel, transport, controlCh, barrierCh, sendCh, inputGate, ackQueue, sendErrCh, log)
1410+
1411+ type foregroundRuntime struct {
1412+ cancel context.CancelFunc
1413+ consumer uint64
1414+ sendLease *foregroundSendLease
1415+ stdinDone, resizeDone chan struct{}
1416+ }
1417+ var foreground *foregroundRuntime
1418+ startForeground := func() {
1419+ if foreground != nil {
1420+ return
1421+ }
1422+ foregroundCtx, foregroundCancel := context.WithCancel(loopCtx)
1423+ consumer := input.claim()
1424+ stdinDone := make(chan struct{})
1425+ resizeDone := make(chan struct{})
1426+ sendLease := newForegroundSendLease()
1427+ foreground = &foregroundRuntime{cancel: foregroundCancel, consumer: consumer, sendLease: sendLease, stdinDone: stdinDone, resizeDone: resizeDone}
1428+ go func() {
1429+ defer close(stdinDone)
1430+ cancelAll := func() {
1431+ foregroundCancel()
1432+ cancel()
1433+ }
1434+ (&stdinPump{ctx: foregroundCtx, cancel: cancelAll, input: input, consumer: consumer, out: sendCh, clock: clk, clipboard: clip, logger: log, paletteEvents: paletteEvents, activeGeneration: &activeGeneration, sendLease: sendLease}).run()
1435+ }()
1436+ go func() {
1437+ defer close(resizeDone)
1438+ runResize(foregroundCtx, term.ResizeEvents(), sendCh, sendLease, log)
1439+ }()
1440+ }
1441+ stopForeground := func() {
1442+ if foreground == nil {
1443+ return
1444+ }
1445+ current := foreground
1446+ foreground = nil
1447+ current.cancel()
1448+ current.sendLease.stop()
1449+ <-current.stdinDone
1450+ <-current.resizeDone
1451+ input.revoke(current.consumer)
1452+ for id := range drainTimers {
1453+ cancelTimer(drainTimers, id)
1454+ }
1455+ for id := range completionTimers {
1456+ cancelTimer(completionTimers, id)
1457+ }
1458+ }
1459+ flushSender := func() error {
1460+ done := make(chan struct{})
1461+ select {
1462+ case barrierCh <- done:
1463+ case <-loopCtx.Done():
1464+ return context.Canceled
1465+ }
1466+ select {
1467+ case <-done:
1468+ return nil
1469+ case <-loopCtx.Done():
1470+ return context.Canceled
1471+ }
1472+ }
1473+ clearParkedFull := func() {
1474+ parkedWaitingFull = false
1475+ if parkedFullTimer != nil {
1476+ parkedFullTimer.Stop()
1477+ parkedFullTimer = nil
1478+ }
1479+ }
1480+ awaitParkedFull := func() {
1481+ clearParkedFull()
1482+ parkedWaitingFull = true
1483+ parkedFullTimer = clk.NewTimer(ports.HandshakeTimeout)
1484+ }
1485+ parkedFullC := func() <-chan time.Time {
1486+ if parkedFullTimer == nil {
1487+ return nil
1488+ }
1489+ return parkedFullTimer.C()
1490+ }
1491+ defer clearParkedFull()
1492+ clearParkedPending := func() {
1493+ if parkedPending != nil && parkedPending.timer != nil {
1494+ parkedPending.timer.Stop()
1495+ }
1496+ parkedPending = nil
1497+ }
1498+ parkedResponseC := func() <-chan time.Time {
1499+ if parkedPending == nil || parkedPending.timer == nil {
1500+ return nil
1501+ }
1502+ return parkedPending.timer.C()
1503+ }
1504+ defer clearParkedPending()
1505+
1506+ sendParkedRouteSize := func() error {
1507+ current, err := term.Size()
1508+ if err != nil {
1509+ return fmt.Errorf("reading terminal size for parked route: %w", err)
1510+ }
1511+ payload, err := ports.MarshalResize(ports.Resize{Size: current})
1512+ if err != nil {
1513+ return fmt.Errorf("encoding terminal size for parked route: %w", err)
1514+ }
1515+ select {
1516+ case controlCh <- ports.Frame{Type: ports.MsgResize, Payload: payload}:
1517+ return nil
1518+ case <-loopCtx.Done():
1519+ return context.Canceled
1520+ }
1521+ }
1522+ sendParkedRouteRequest := func(action ports.ParkedRouteAction, leaseID ports.ParkedRouteLeaseID, target *domain.RemoteSessionTarget) error {
1523+ if parkedPending != nil {
1524+ return errors.New("vev: parked-route request already pending")
1525+ }
1526+ nextParkedRequestID++
1527+ request := ports.ParkedRouteRequest{RequestID: nextParkedRequestID, LeaseID: leaseID, Action: action, Target: target}
1528+ payload := ports.MarshalParkedRouteRequest(request)
1529+ if payload == nil {
1530+ return errors.New("vev: invalid parked-route request")
1531+ }
1532+ select {
1533+ case controlCh <- ports.Frame{Type: ports.MsgParkedRouteRequest, Payload: payload}:
1534+ parkedPending = &parkedRoutePending{
1535+ requestID: request.RequestID, action: action, leaseID: leaseID,
1536+ timer: clk.NewTimer(ports.HandshakeTimeout),
1537+ }
1538+ return nil
1539+ case <-loopCtx.Done():
1540+ return context.Canceled
1541+ }
1542+ }
1543+ handleParkedPicker := func(leaseID ports.ParkedRouteLeaseID) (attachResult, bool) {
1544+ pickerCtx, cancelPicker := context.WithCancel(ctx)
1545+ pickerDone := make(chan struct{})
1546+ go func() {
1547+ select {
1548+ case <-transportFailed:
1549+ cancelPicker()
1550+ case <-pickerDone:
1551+ }
1552+ }()
1553+ selection := a.openHomePicker(pickerCtx)
1554+ close(pickerDone)
1555+ cancelPicker()
1556+ select {
1557+ case <-transportFailed:
1558+ return welcomedResult(errLinkOffline), true
1559+ default:
1560+ }
1561+ if selection.target != nil && transition != nil {
1562+ transitionWaitingFull = false
1563+ transition.start(*selection.target)
1564+ }
1565+ if selection.target != nil && request.Remote && request.OriginKey != "" && selection.target.Endpoint == request.OriginKey && selection.target.RemoteTarget != nil {
1566+ target := *selection.target.RemoteTarget
1567+ if err := sendParkedRouteSize(); err != nil {
1568+ return welcomedResult(err), true
1569+ }
1570+ if err := sendParkedRouteRequest(ports.ParkedRouteSwitch, leaseID, &target); err != nil {
1571+ return welcomedResult(fmt.Errorf("vev: switching parked route: %w", err)), true
1572+ }
1573+ fallback := *selection.target
1574+ parkedPending.fallbackTarget = &fallback
1575+ return attachResult{}, false
1576+ }
1577+ if selection.target != nil {
1578+ selection.homePickerTarget = true
1579+ return selection, true
1580+ }
1581+ if selection.err != nil {
1582+ log.Warn("transient home picker failed; resuming parked route", "err", selection.err)
1583+ }
1584+ if err := sendParkedRouteSize(); err != nil {
1585+ return welcomedResult(err), true
1586+ }
1587+ if err := sendParkedRouteRequest(ports.ParkedRouteResume, leaseID, nil); err != nil {
1588+ return welcomedResult(fmt.Errorf("vev: resuming parked route: %w", err)), true
1589+ }
1590+ return attachResult{}, false
1591+ }
1592+ startForeground()
1593+ defer stopForeground()
13591594
13601595 // 5. Output/main loop: the only goroutine that touches the terminal.
13611596 recvCh := make(chan recvResult, 1)
1362- go runRecv(loopCtx, transport, recvCh, log)
1597+ go runRecv(loopCtx, transport, recvCh, transportFailed, log)
13631598
13641599 requestReconnectReset := func() error {
13651600 if awaitingReconnectReset {
@@ -1447,6 +1682,16 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
14471682 select {
14481683 case <-loopCtx.Done():
14491684 return loopCanceledResult()
1685+ case <-transition.tickC():
1686+ if err := transition.advance(); err != nil {
1687+ return welcomedResult(err)
1688+ }
1689+ case <-parkedResponseC():
1690+ clearParkedPending()
1691+ return welcomedResult(errors.New("vev: timed out waiting for parked-route response"))
1692+ case <-parkedFullC():
1693+ clearParkedFull()
1694+ return welcomedResult(errors.New("vev: timed out waiting for parked-route full output"))
14501695 case ev, ok := <-linkEvents:
14511696 if !ok {
14521697 linkEvents = nil
@@ -1537,6 +1782,9 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
15371782 }
15381783 continue
15391784 }
1785+ if parkedWaitingFull && !o.Full {
1786+ return welcomedResult(errors.New("vev: parked route resumed without an authoritative full output"))
1787+ }
15401788 if _, werr := term.Out().Write(o.Data); werr != nil {
15411789 return welcomedResult(fmt.Errorf("vev: writing terminal output: %w", werr))
15421790 }
@@ -1555,6 +1803,14 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
15551803 return welcomedResult(fmt.Errorf("vev: flushing terminal: %w", ferr))
15561804 }
15571805 outputState = nextState
1806+ if transition != nil && transition.active && (!transitionWaitingFull || o.Full) {
1807+ transitionWaitingFull = false
1808+ transition.stop()
1809+ }
1810+ if parkedWaitingFull {
1811+ clearParkedFull()
1812+ startForeground()
1813+ }
15581814 if o.New != 0 {
15591815 ackQueue.offer(o.Epoch, o.New)
15601816 outputResetRequested = false
@@ -1573,6 +1829,15 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
15731829 if derr != nil {
15741830 return welcomedResult(fmt.Errorf("vev: decoding attach target: %w", derr))
15751831 }
1832+ if a.returnAttachTargets {
1833+ handoff := welcomedResult(nil)
1834+ handoff.target = &target
1835+ return handoff
1836+ }
1837+ if transition != nil {
1838+ transitionWaitingFull = false
1839+ transition.start(target)
1840+ }
15761841 if target.Endpoint == "" && target.RemoteTarget == nil && target.ExactTarget != nil && a.runner.ledger != nil {
15771842 if samePeerSwitch != nil {
15781843 return welcomedResult(errors.New("vev: concurrent same-peer switch offer"))
@@ -1599,13 +1864,71 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
15991864 handoff := welcomedResult(nil)
16001865 handoff.target = &target
16011866 return handoff
1867+ case ports.MsgParkedRouteResponse:
1868+ response, derr := ports.UnmarshalParkedRouteResponse(r.frame.Payload)
1869+ if derr != nil {
1870+ return welcomedResult(fmt.Errorf("vev: decoding parked-route response: %w", derr))
1871+ }
1872+ if parkedPending == nil || response.RequestID != parkedPending.requestID {
1873+ return welcomedResult(errors.New("vev: unexpected parked-route response"))
1874+ }
1875+ pending := *parkedPending
1876+ clearParkedPending()
1877+ switch pending.action {
1878+ case ports.ParkedRoutePrepare:
1879+ if response.Status != ports.ParkedRouteReady {
1880+ fallback := welcomedResult(nil)
1881+ fallback.action = ports.NavigationOpenHomePicker
1882+ return fallback
1883+ }
1884+ if selection, done := handleParkedPicker(pending.leaseID); done {
1885+ return selection
1886+ }
1887+ case ports.ParkedRouteResume:
1888+ if response.Status != ports.ParkedRouteResumed {
1889+ return welcomedResult(errors.New("vev: parked route could not resume"))
1890+ }
1891+ awaitParkedFull()
1892+ case ports.ParkedRouteSwitch:
1893+ switch response.Status {
1894+ case ports.ParkedRouteSwitched:
1895+ awaitParkedFull()
1896+ continue
1897+ case ports.ParkedRouteStaleTarget:
1898+ if transition != nil {
1899+ transition.stop()
1900+ }
1901+ if selection, done := handleParkedPicker(pending.leaseID); done {
1902+ return selection
1903+ }
1904+ default:
1905+ if pending.fallbackTarget == nil {
1906+ return welcomedResult(errors.New("vev: parked route switch failed"))
1907+ }
1908+ fallback := welcomedResult(nil)
1909+ fallback.target = pending.fallbackTarget
1910+ fallback.homePickerTarget = true
1911+ return fallback
1912+ }
1913+ }
16021914 case ports.MsgNavigationAction:
1603- action, derr := ports.UnmarshalNavigationAction(r.frame.Payload)
1915+ directive, derr := ports.UnmarshalNavigationDirective(r.frame.Payload)
16041916 if derr != nil {
16051917 return welcomedResult(fmt.Errorf("vev: decoding navigation action: %w", derr))
16061918 }
1919+ _, datagramRoute := transport.(ports.DatagramTransport)
1920+ if directive.Action == ports.NavigationOpenHomePicker && a.openHomePicker != nil && datagramRoute {
1921+ stopForeground()
1922+ if err := flushSender(); err != nil {
1923+ return welcomedResult(fmt.Errorf("vev: parking remote foreground: %w", err))
1924+ }
1925+ if err := sendParkedRouteRequest(ports.ParkedRoutePrepare, directive.LeaseID, nil); err != nil {
1926+ return welcomedResult(fmt.Errorf("vev: preparing parked route: %w", err))
1927+ }
1928+ continue
1929+ }
16071930 navigation := welcomedResult(nil)
1608- navigation.action = action
1931+ navigation.action = directive.Action
16091932 return navigation
16101933 case ports.MsgNavigateRecentRoute:
16111934 action, derr := ports.UnmarshalRouteNavigationAction(r.frame.Payload)
@@ -1662,6 +1985,19 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
16621985 if samePeerSwitch != nil && failure.RequestID == samePeerSwitch.requestID {
16631986 samePeerSwitch = nil
16641987 inputGate.setPaused(false)
1988+ if transition != nil && transition.showing {
1989+ transitionWaitingFull = true
1990+ if !outputResetRequested {
1991+ select {
1992+ case sendCh <- ports.Frame{Type: ports.MsgOutputResetRequest, Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{})}:
1993+ outputResetRequested = true
1994+ case <-loopCtx.Done():
1995+ return loopCanceledResult()
1996+ }
1997+ }
1998+ } else if transition != nil {
1999+ transition.stop()
2000+ }
16652001 }
16662002 case ports.MsgRoutePosition:
16672003 position, derr := ports.UnmarshalRoutePosition(r.frame.Payload)
@@ -1712,10 +2048,13 @@ type recvResult struct {
17122048
17132049 // runRecv reads frames until an error, forwarding each to the main loop.
17142050 // It exits on the first error or when the loop context is cancelled.
1715-func runRecv(ctx context.Context, transport ports.Transport, out chan<- recvResult, log *slog.Logger) {
2051+func runRecv(ctx context.Context, transport ports.Transport, out chan<- recvResult, failed chan<- struct{}, log *slog.Logger) {
17162052 defer log.Debug("receive pump exited")
17172053 for {
17182054 f, err := transport.Recv()
2055+ if err != nil && failed != nil {
2056+ close(failed)
2057+ }
17192058 select {
17202059 case out <- recvResult{frame: f, err: err}:
17212060 case <-ctx.Done():
@@ -1766,7 +2105,7 @@ func (q *cumulativeAckQueue) take() (uint64, uint64) {
17662105
17672106 // runSender preserves normal-frame order while allowing switch control and
17682107 // output ACKs to make progress when raw input is held for a same-peer switch.
1769-func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.Transport, control, in <-chan ports.Frame, inputGate *samePeerInputGate, acks *cumulativeAckQueue, errCh chan<- error, log *slog.Logger) {
2108+func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.Transport, control <-chan ports.Frame, barriers <-chan chan struct{}, in <-chan ports.Frame, inputGate *samePeerInputGate, acks *cumulativeAckQueue, errCh chan<- error, log *slog.Logger) {
17702109 defer log.Debug("sender pump exited")
17712110 send := func(f ports.Frame) bool {
17722111 if err := transport.Send(f); err != nil {
@@ -1795,6 +2134,36 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T
17952134 }
17962135 return send(ports.Frame{Type: ports.MsgAck, Payload: payload})
17972136 }
2137+ flushPending := func() bool {
2138+ for {
2139+ select {
2140+ case <-acks.wake:
2141+ if !sendAck() {
2142+ return false
2143+ }
2144+ continue
2145+ default:
2146+ }
2147+ if paused, _ := inputGate.snapshot(); paused {
2148+ return sendAck()
2149+ }
2150+ select {
2151+ case frame := <-in:
2152+ if !send(frame) {
2153+ return false
2154+ }
2155+ default:
2156+ return sendAck()
2157+ }
2158+ }
2159+ }
2160+ completeBarrier := func(done chan struct{}) bool {
2161+ if !flushPending() {
2162+ return false
2163+ }
2164+ close(done)
2165+ return true
2166+ }
17982167 var heldInput *ports.Frame
17992168 for {
18002169 if heldInput != nil {
@@ -1815,6 +2184,10 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T
18152184 if !send(frame) {
18162185 return
18172186 }
2187+ case done := <-barriers:
2188+ if !completeBarrier(done) {
2189+ return
2190+ }
18182191 case <-changed:
18192192 case <-ctx.Done():
18202193 return
@@ -1832,6 +2205,11 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T
18322205 return
18332206 }
18342207 continue
2208+ case done := <-barriers:
2209+ if !completeBarrier(done) {
2210+ return
2211+ }
2212+ continue
18352213 default:
18362214 }
18372215 select {
@@ -1843,6 +2221,10 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T
18432221 if !send(frame) {
18442222 return
18452223 }
2224+ case done := <-barriers:
2225+ if !completeBarrier(done) {
2226+ return
2227+ }
18462228 case frame := <-in:
18472229 select {
18482230 case <-ctx.Done():
@@ -2185,6 +2567,36 @@ func (p *terminalInputPump) stop() {
21852567 // preserving all ordinary terminal input byte-for-byte. It never reads the
21862568 // terminal directly, publishes a Theme, or writes terminal output;
21872569 // attachAttempt owns those operations.
2570+type foregroundSendLease struct {
2571+ mu sync.Mutex
2572+ active bool
2573+}
2574+
2575+func newForegroundSendLease() *foregroundSendLease {
2576+ return &foregroundSendLease{active: true}
2577+}
2578+
2579+func (l *foregroundSendLease) send(send func() bool) bool {
2580+ if l == nil {
2581+ return send()
2582+ }
2583+ l.mu.Lock()
2584+ defer l.mu.Unlock()
2585+ if !l.active {
2586+ return false
2587+ }
2588+ return send()
2589+}
2590+
2591+func (l *foregroundSendLease) stop() {
2592+ if l == nil {
2593+ return
2594+ }
2595+ l.mu.Lock()
2596+ l.active = false
2597+ l.mu.Unlock()
2598+}
2599+
21882600 type stdinPump struct {
21892601 ctx context.Context
21902602 cancel context.CancelFunc
@@ -2200,6 +2612,7 @@ type stdinPump struct {
22002612 afterPaletteEvent func(paletteGenerationEvent) // test synchronization hook
22012613 afterInputTake func() // test synchronization hook
22022614 afterInputDelivered func() // test synchronization hook
2615+ sendLease *foregroundSendLease
22032616 }
22042617
22052618 func (p *stdinPump) run() {
@@ -2215,31 +2628,35 @@ func (p *stdinPump) run() {
22152628 if !sendOK.Load() {
22162629 return false
22172630 }
2218- select {
2219- case p.out <- frame:
2220- if p.afterInputDelivered != nil {
2221- p.afterInputDelivered()
2631+ return p.sendLease.send(func() bool {
2632+ select {
2633+ case p.out <- frame:
2634+ if p.afterInputDelivered != nil {
2635+ p.afterInputDelivered()
2636+ }
2637+ return true
2638+ case <-p.ctx.Done():
2639+ sendOK.Store(false)
2640+ return false
22222641 }
2223- return true
2224- case <-p.ctx.Done():
2225- sendOK.Store(false)
2226- return false
2227- }
2642+ })
22282643 }
22292644 sendEvent := func(event paletteGenerationEvent) bool {
22302645 if !sendOK.Load() {
22312646 return false
22322647 }
2233- select {
2234- case p.paletteEvents <- event:
2235- if p.afterPaletteEvent != nil {
2236- p.afterPaletteEvent(event)
2648+ return p.sendLease.send(func() bool {
2649+ select {
2650+ case p.paletteEvents <- event:
2651+ if p.afterPaletteEvent != nil {
2652+ p.afterPaletteEvent(event)
2653+ }
2654+ return true
2655+ case <-p.ctx.Done():
2656+ sendOK.Store(false)
2657+ return false
22372658 }
2238- return true
2239- case <-p.ctx.Done():
2240- sendOK.Store(false)
2241- return false
2242- }
2659+ })
22432660 }
22442661 coalescer := newPasteCoalescer(p.clock, func(data []byte) {
22452662 if len(data) == 0 {
@@ -2427,7 +2844,7 @@ func (p *stdinPump) run() {
24272844 // runResize forwards coalesced terminal resize events to the daemon. It
24282845 // tolerates an already-closed resize channel (which the terminal adapter
24292846 // hands back when restore ran before ResizeEvents was first called).
2430-func runResize(ctx context.Context, events <-chan domain.Size, out chan<- ports.Frame, log *slog.Logger) {
2847+func runResize(ctx context.Context, events <-chan domain.Size, out chan<- ports.Frame, sendLease *foregroundSendLease, log *slog.Logger) {
24312848 defer log.Debug("resize pump exited")
24322849 for {
24332850 select {
@@ -2441,9 +2858,14 @@ func runResize(ctx context.Context, events <-chan domain.Size, out chan<- ports.
24412858 continue
24422859 }
24432860 frame := ports.Frame{Type: ports.MsgResize, Payload: payload}
2444- select {
2445- case out <- frame:
2446- case <-ctx.Done():
2861+ if !sendLease.send(func() bool {
2862+ select {
2863+ case out <- frame:
2864+ return true
2865+ case <-ctx.Done():
2866+ return false
2867+ }
2868+ }) {
24472869 return
24482870 }
24492871 case <-ctx.Done():
@@ -398,6 +398,8 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
398 rawEntered: &rawEntered,398 rawEntered: &rawEntered,
399 stage: reconnectStageOfflineRetrying,399 stage: reconnectStageOfflineRetrying,
400 }400 }
401+ transition := newTransitionUI(r.term, r.clock, &rawEntered)
402+ defer transition.stop()
401 restoreReturnRoute := func(route attachRoute) {403 restoreReturnRoute := func(route attachRoute) {
402 returnNavigationPending = true404 returnNavigationPending = true
403 dialer = route.dialer405 dialer = route.dialer
@@ -444,12 +446,58 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
444 return nil446 return nil
445 }447 }
446 448
449+ runTransientHomePicker := func(attemptCtx context.Context) attachResult {
450+ if homeRoute == nil {
451+ return attachResult{err: errors.New("vev: home route unavailable")}
452+ }
453+ request := cloneAttachRequest(homeRoute.request)
454+ request.Intent = ports.IntentAttach
455+ request.NavigationCapabilities = ports.NavigationCapabilityBack
456+ request.StartupOverlay = ports.StartupOverlaySessionPicker
457+ request.RemoteTarget = nil
458+ request.EnvironmentPolicy = ports.EnvironmentPolicyClientOwned
459+ request.Remote = false
460+ if err := validateAttachRequest(request); err != nil {
461+ return attachResult{err: err}
462+ }
463+ handshakeCtx, timedOut, finishHandshake := newHandshakeContext(attemptCtx, r.clock)
464+ transport, err := boundedDial(handshakeCtx, homeRoute.dialer)
465+ if err != nil {
466+ err = handshakeContextError(attemptCtx, timedOut, err)
467+ finishHandshake()
468+ return attachResult{err: err}
469+ }
470+ connection, err := NewSessionConnection(transport, SessionTarget{Intent: request.Intent, SessionName: request.SessionName})
471+ if err != nil {
472+ finishHandshake()
473+ _ = transport.Close()
474+ return attachResult{err: err}
475+ }
476+ stopHandshakeTransport := watchHandshakeTransport(handshakeCtx, transport)
477+ localReconnect := &reconnectUI{term: r.term, rawEntered: &rawEntered}
478+ result := (&attachAttempt{
479+ runner: r, dialer: homeRoute.dialer, connection: connection,
480+ handshakeCtx: handshakeCtx, handshakeTimedOut: timedOut,
481+ finishHandshake: finishHandshake, stopHandshakeTransport: stopHandshakeTransport,
482+ request: request, clientID: processClientID, milestones: &ms,
483+ themeState: themeState, enterRaw: enterRaw, reconnect: localReconnect,
484+ terminalInput: func() *terminalInputPump { return input },
485+ transientPicker: true, returnAttachTargets: true,
486+ }).run(attemptCtx)
487+ stopHandshakeTransport()
488+ finishHandshake()
489+ if !result.transportClosed {
490+ _ = connection.Close()
491+ }
492+ return result
493+ }
494+
447 for {495 for {
448 if err := validateAttachRequest(attemptRequest); err != nil {496 if err := validateAttachRequest(attemptRequest); err != nil {
449 return err497 return err
450 }498 }
451 handshakeCtx, timedOut, finishHandshake := newHandshakeContext(ctx, r.clock)499 handshakeCtx, timedOut, finishHandshake := newHandshakeContext(ctx, r.clock)
452- transport, err := boundedDial(handshakeCtx, dialer)500+ transport, err := boundedDialWithTransition(handshakeCtx, dialer, transition)
453 if err != nil {501 if err != nil {
454 err = handshakeContextError(ctx, timedOut, err)502 err = handshakeContextError(ctx, timedOut, err)
455 finishHandshake()503 finishHandshake()
@@ -515,11 +563,13 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
515 themeState: themeState,563 themeState: themeState,
516 enterRaw: enterRaw,564 enterRaw: enterRaw,
517 reconnect: reconnect,565 reconnect: reconnect,
566+ transition: transition,
518 linkEvents: linkEvents,567 linkEvents: linkEvents,
519 rememberRemoteHost: rememberRemoteHost,568 rememberRemoteHost: rememberRemoteHost,
520 terminalInput: func() *terminalInputPump {569 terminalInput: func() *terminalInputPump {
521 return input570 return input
522 },571 },
572+ openHomePicker: runTransientHomePicker,
523 }).run(ctx)573 }).run(ctx)
524 stopHandshakeTransport()574 stopHandshakeTransport()
525 finishHandshake()575 finishHandshake()
@@ -655,8 +705,16 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
655 if r.ledger == nil {705 if r.ledger == nil {
656 return errors.New("vev: route ledger unavailable")706 return errors.New("vev: route ledger unavailable")
657 }707 }
708+ baseRequest := attemptRequest
658 nextDialer = dialer709 nextDialer = dialer
659- nextRequest = r.ledger.samePeerHandoff(attemptRequest, *result.target)710+ if result.homePickerTarget {
711+ if homeRoute == nil {
712+ return errors.New("vev: home route unavailable for picker target")
713+ }
714+ nextDialer = homeRoute.dialer
715+ baseRequest = homeRoute.request
716+ }
717+ nextRequest = r.ledger.samePeerHandoff(baseRequest, *result.target)
660 } else {718 } else {
661 if r.attachHandoff == nil {719 if r.attachHandoff == nil {
662 return &AttachTargetError{Target: *result.target}720 return &AttachTargetError{Target: *result.target}
@@ -910,9 +968,13 @@ type attachAttempt struct {
910 themeState *terminalThemeState968 themeState *terminalThemeState
911 enterRaw func() error969 enterRaw func() error
912 reconnect *reconnectUI970 reconnect *reconnectUI
971+ transition *transitionUI
913 linkEvents <-chan ports.LinkEvent972 linkEvents <-chan ports.LinkEvent
914 rememberRemoteHost func()973 rememberRemoteHost func()
915 terminalInput func() *terminalInputPump974 terminalInput func() *terminalInputPump
975+ openHomePicker func(context.Context) attachResult
976+ transientPicker bool
977+ returnAttachTargets bool
916 }978 }
917 979
918 type attachResult struct {980 type attachResult struct {
@@ -924,6 +986,7 @@ type attachResult struct {
924 transportClosed bool986 transportClosed bool
925 target *ports.AttachTarget987 target *ports.AttachTarget
926 action ports.NavigationAction988 action ports.NavigationAction
989+ homePickerTarget bool
927 routeAction *ports.RouteNavigationAction990 routeAction *ports.RouteNavigationAction
928 err error991 err error
929 }992 }
@@ -979,6 +1042,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
979 themeState := a.themeState1042 themeState := a.themeState
980 enterRaw := a.enterRaw1043 enterRaw := a.enterRaw
981 reconnect := a.reconnect1044 reconnect := a.reconnect
1045+ transition := a.transition
982 linkEvents := a.linkEvents1046 linkEvents := a.linkEvents
983 remote := a.remote || a.request.Remote1047 remote := a.remote || a.request.Remote
984 clipboard := a.runner.clipboard1048 clipboard := a.runner.clipboard
@@ -1013,7 +1077,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1013 return attachResult{transportClosed: handshakeCtx.Err() != nil, err: fmt.Errorf("vev: %s: %w", stage, err)}1077 return attachResult{transportClosed: handshakeCtx.Err() != nil, err: fmt.Errorf("vev: %s: %w", stage, err)}
1014 }1078 }
1015 sendHandshake := func(operation func() error) error {1079 sendHandshake := func(operation func() error) error {
1016- err := boundedHandshakeOperation(handshakeCtx, transport, operation)1080+ err := boundedHandshakeOperationWithTransition(handshakeCtx, transport, operation, transition)
1017 if err != nil {1081 if err != nil {
1018 return handshakeContextError(ctx, handshakeTimedOut, err)1082 return handshakeContextError(ctx, handshakeTimedOut, err)
1019 }1083 }
@@ -1067,11 +1131,11 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1067 1131
1068 // 2. Await Welcome or a typed rejection.1132 // 2. Await Welcome or a typed rejection.
1069 var reply ports.Frame1133 var reply ports.Frame
1070- if err := boundedHandshakeOperation(handshakeCtx, transport, func() error {1134+ if err := boundedHandshakeOperationWithTransition(handshakeCtx, transport, func() error {
1071 var recvErr error1135 var recvErr error
1072 reply, recvErr = transport.Recv()1136 reply, recvErr = transport.Recv()
1073 return recvErr1137 return recvErr
1074- }); err != nil {1138+ }, transition); err != nil {
1075 return handshakeFailure("awaiting welcome", err)1139 return handshakeFailure("awaiting welcome", err)
1076 }1140 }
1077 var committedIdentity *ports.CommittedRouteIdentity1141 var committedIdentity *ports.CommittedRouteIdentity
@@ -1122,16 +1186,18 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1122 if request.RemoteTarget != nil && (request.RemoteTarget.LifecycleID != committedIdentity.Target.LifecycleID || request.RemoteTarget.SessionName != committedIdentity.Target.SessionName) {1186 if request.RemoteTarget != nil && (request.RemoteTarget.LifecycleID != committedIdentity.Target.LifecycleID || request.RemoteTarget.SessionName != committedIdentity.Target.SessionName) {
1123 return welcomedResult(errRouteTargetChanged)1187 return welcomedResult(errRouteTargetChanged)
1124 }1188 }
1125- candidate := routeCandidateForAttach(request, *committedIdentity, a.dialer, resumeToken)1189+ if !a.transientPicker {
1126- var commitErr error1190+ candidate := routeCandidateForAttach(request, *committedIdentity, a.dialer, resumeToken)
1127- if a.routeNavigationSelection != nil {1191+ var commitErr error
1128- selection := *a.routeNavigationSelection1192+ if a.routeNavigationSelection != nil {
1129- commitErr = a.runner.ledger.commitTransition(selection.selected.identity, selection.snapshotGeneration, selection.active, candidate)1193+ selection := *a.routeNavigationSelection
1130- } else {1194+ commitErr = a.runner.ledger.commitTransition(selection.selected.identity, selection.snapshotGeneration, selection.active, candidate)
1131- _, commitErr = a.runner.ledger.commitAttach(candidate)1195+ } else {
1132- }1196+ _, commitErr = a.runner.ledger.commitAttach(candidate)
1133- if commitErr != nil {1197+ }
1134- return welcomedResult(fmt.Errorf("vev: committing route identity: %w", commitErr))1198+ if commitErr != nil {
1199+ return welcomedResult(fmt.Errorf("vev: committing route identity: %w", commitErr))
1200+ }
1135 }1201 }
1136 snapshotPayload, err := ports.MarshalRecentRouteSnapshot(a.runner.ledger.snapshot())1202 snapshotPayload, err := ports.MarshalRecentRouteSnapshot(a.runner.ledger.snapshot())
1137 if err != nil {1203 if err != nil {
@@ -1183,21 +1249,9 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1183 input.start()1249 input.start()
1184 defer input.stop()1250 defer input.stop()
1185 }1251 }
1186- inputConsumer := input.claim()
1187- claimActive := true
1188- revokeInputClaim := func() {
1189- if claimActive {
1190- input.revoke(inputConsumer)
1191- claimActive = false
1192- }
1193- }
1194- // Register revocation before any palette publication or terminal I/O: both
1195- // can fail before the stdin scanner is started, and a reconnect must still
1196- // be able to claim this lifecycle-owned reader.
1197- defer revokeInputClaim()
1198-
1199 sendCh := make(chan ports.Frame, sendQueueDepth)1252 sendCh := make(chan ports.Frame, sendQueueDepth)
1200 controlCh := make(chan ports.Frame, 1)1253 controlCh := make(chan ports.Frame, 1)
1254+ barrierCh := make(chan chan struct{})
1201 inputGate := newSamePeerInputGate()1255 inputGate := newSamePeerInputGate()
1202 ackQueue := newCumulativeAckQueue()1256 ackQueue := newCumulativeAckQueue()
1203 sendErrCh := make(chan error, 1)1257 sendErrCh := make(chan error, 1)
@@ -1309,8 +1363,21 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1309 awaitingReconnectReset := false1363 awaitingReconnectReset := false
1310 outputState := outputApplyState{}1364 outputState := outputApplyState{}
1311 outputResetRequested := false1365 outputResetRequested := false
1366+ transitionWaitingFull := false
1312 var samePeerSwitch *samePeerSwitchPending1367 var samePeerSwitch *samePeerSwitchPending
1313 var nextSamePeerRequestID uint641368 var nextSamePeerRequestID uint64
1369+ type parkedRoutePending struct {
1370+ requestID uint64
1371+ action ports.ParkedRouteAction
1372+ leaseID ports.ParkedRouteLeaseID
1373+ fallbackTarget *ports.AttachTarget
1374+ timer ports.Timer
1375+ }
1376+ var parkedPending *parkedRoutePending
1377+ var nextParkedRequestID uint64
1378+ parkedWaitingFull := false
1379+ var parkedFullTimer ports.Timer
1380+ transportFailed := make(chan struct{})
1314 if reconnect.showing {1381 if reconnect.showing {
1315 if reconnect.remote {1382 if reconnect.remote {
1316 // Before the asynchronous sender starts, preserve transport ordering by1383 // Before the asynchronous sender starts, preserve transport ordering by
@@ -1339,27 +1406,195 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1339 // or unchanged screen may legitimately produce no initial Output frame.1406 // or unchanged screen may legitimately produce no initial Output frame.
1340 endHandshake()1407 endHandshake()
1341 senderStarted = true1408 senderStarted = true
1342- go runSender(loopCtx, cancel, transport, controlCh, sendCh, inputGate, ackQueue, sendErrCh, log)1409+ go runSender(loopCtx, cancel, transport, controlCh, barrierCh, sendCh, inputGate, ackQueue, sendErrCh, log)
1343- stdinDone := make(chan struct{})1410+
1344- go func() {1411+ type foregroundRuntime struct {
1345- defer close(stdinDone)1412+ cancel context.CancelFunc
1346- (&stdinPump{ctx: loopCtx, cancel: cancel, input: input, consumer: inputConsumer, out: sendCh, clock: clk, clipboard: clip, logger: log, paletteEvents: paletteEvents, activeGeneration: &activeGeneration}).run()1413+ consumer uint64
1347- }()1414+ sendLease *foregroundSendLease
1348- // Scanner cancellation can leave an undecided marker suffix (including a1415+ stdinDone, resizeDone chan struct{}
1349- // standalone Escape) that it must hand back to the lifecycle-owned reader.1416+ }
1350- // Wait for that handoff before revoking the claim: otherwise a replacement1417+ var foreground *foregroundRuntime
1351- // scanner could claim the reader between revocation and preservation and1418+ startForeground := func() {
1352- // lose the suffix.1419+ if foreground != nil {
1353- defer func() {1420+ return
1354- cancel()1421+ }
1355- <-stdinDone1422+ foregroundCtx, foregroundCancel := context.WithCancel(loopCtx)
1356- revokeInputClaim()1423+ consumer := input.claim()
1357- }()1424+ stdinDone := make(chan struct{})
1358- go runResize(loopCtx, term.ResizeEvents(), sendCh, log)1425+ resizeDone := make(chan struct{})
1426+ sendLease := newForegroundSendLease()
1427+ foreground = &foregroundRuntime{cancel: foregroundCancel, consumer: consumer, sendLease: sendLease, stdinDone: stdinDone, resizeDone: resizeDone}
1428+ go func() {
1429+ defer close(stdinDone)
1430+ cancelAll := func() {
1431+ foregroundCancel()
1432+ cancel()
1433+ }
1434+ (&stdinPump{ctx: foregroundCtx, cancel: cancelAll, input: input, consumer: consumer, out: sendCh, clock: clk, clipboard: clip, logger: log, paletteEvents: paletteEvents, activeGeneration: &activeGeneration, sendLease: sendLease}).run()
1435+ }()
1436+ go func() {
1437+ defer close(resizeDone)
1438+ runResize(foregroundCtx, term.ResizeEvents(), sendCh, sendLease, log)
1439+ }()
1440+ }
1441+ stopForeground := func() {
1442+ if foreground == nil {
1443+ return
1444+ }
1445+ current := foreground
1446+ foreground = nil
1447+ current.cancel()
1448+ current.sendLease.stop()
1449+ <-current.stdinDone
1450+ <-current.resizeDone
1451+ input.revoke(current.consumer)
1452+ for id := range drainTimers {
1453+ cancelTimer(drainTimers, id)
1454+ }
1455+ for id := range completionTimers {
1456+ cancelTimer(completionTimers, id)
1457+ }
1458+ }
1459+ flushSender := func() error {
1460+ done := make(chan struct{})
1461+ select {
1462+ case barrierCh <- done:
1463+ case <-loopCtx.Done():
1464+ return context.Canceled
1465+ }
1466+ select {
1467+ case <-done:
1468+ return nil
1469+ case <-loopCtx.Done():
1470+ return context.Canceled
1471+ }
1472+ }
1473+ clearParkedFull := func() {
1474+ parkedWaitingFull = false
1475+ if parkedFullTimer != nil {
1476+ parkedFullTimer.Stop()
1477+ parkedFullTimer = nil
1478+ }
1479+ }
1480+ awaitParkedFull := func() {
1481+ clearParkedFull()
1482+ parkedWaitingFull = true
1483+ parkedFullTimer = clk.NewTimer(ports.HandshakeTimeout)
1484+ }
1485+ parkedFullC := func() <-chan time.Time {
1486+ if parkedFullTimer == nil {
1487+ return nil
1488+ }
1489+ return parkedFullTimer.C()
1490+ }
1491+ defer clearParkedFull()
1492+ clearParkedPending := func() {
1493+ if parkedPending != nil && parkedPending.timer != nil {
1494+ parkedPending.timer.Stop()
1495+ }
1496+ parkedPending = nil
1497+ }
1498+ parkedResponseC := func() <-chan time.Time {
1499+ if parkedPending == nil || parkedPending.timer == nil {
1500+ return nil
1501+ }
1502+ return parkedPending.timer.C()
1503+ }
1504+ defer clearParkedPending()
1505+
1506+ sendParkedRouteSize := func() error {
1507+ current, err := term.Size()
1508+ if err != nil {
1509+ return fmt.Errorf("reading terminal size for parked route: %w", err)
1510+ }
1511+ payload, err := ports.MarshalResize(ports.Resize{Size: current})
1512+ if err != nil {
1513+ return fmt.Errorf("encoding terminal size for parked route: %w", err)
1514+ }
1515+ select {
1516+ case controlCh <- ports.Frame{Type: ports.MsgResize, Payload: payload}:
1517+ return nil
1518+ case <-loopCtx.Done():
1519+ return context.Canceled
1520+ }
1521+ }
1522+ sendParkedRouteRequest := func(action ports.ParkedRouteAction, leaseID ports.ParkedRouteLeaseID, target *domain.RemoteSessionTarget) error {
1523+ if parkedPending != nil {
1524+ return errors.New("vev: parked-route request already pending")
1525+ }
1526+ nextParkedRequestID++
1527+ request := ports.ParkedRouteRequest{RequestID: nextParkedRequestID, LeaseID: leaseID, Action: action, Target: target}
1528+ payload := ports.MarshalParkedRouteRequest(request)
1529+ if payload == nil {
1530+ return errors.New("vev: invalid parked-route request")
1531+ }
1532+ select {
1533+ case controlCh <- ports.Frame{Type: ports.MsgParkedRouteRequest, Payload: payload}:
1534+ parkedPending = &parkedRoutePending{
1535+ requestID: request.RequestID, action: action, leaseID: leaseID,
1536+ timer: clk.NewTimer(ports.HandshakeTimeout),
1537+ }
1538+ return nil
1539+ case <-loopCtx.Done():
1540+ return context.Canceled
1541+ }
1542+ }
1543+ handleParkedPicker := func(leaseID ports.ParkedRouteLeaseID) (attachResult, bool) {
1544+ pickerCtx, cancelPicker := context.WithCancel(ctx)
1545+ pickerDone := make(chan struct{})
1546+ go func() {
1547+ select {
1548+ case <-transportFailed:
1549+ cancelPicker()
1550+ case <-pickerDone:
1551+ }
1552+ }()
1553+ selection := a.openHomePicker(pickerCtx)
1554+ close(pickerDone)
1555+ cancelPicker()
1556+ select {
1557+ case <-transportFailed:
1558+ return welcomedResult(errLinkOffline), true
1559+ default:
1560+ }
1561+ if selection.target != nil && transition != nil {
1562+ transitionWaitingFull = false
1563+ transition.start(*selection.target)
1564+ }
1565+ if selection.target != nil && request.Remote && request.OriginKey != "" && selection.target.Endpoint == request.OriginKey && selection.target.RemoteTarget != nil {
1566+ target := *selection.target.RemoteTarget
1567+ if err := sendParkedRouteSize(); err != nil {
1568+ return welcomedResult(err), true
1569+ }
1570+ if err := sendParkedRouteRequest(ports.ParkedRouteSwitch, leaseID, &target); err != nil {
1571+ return welcomedResult(fmt.Errorf("vev: switching parked route: %w", err)), true
1572+ }
1573+ fallback := *selection.target
1574+ parkedPending.fallbackTarget = &fallback
1575+ return attachResult{}, false
1576+ }
1577+ if selection.target != nil {
1578+ selection.homePickerTarget = true
1579+ return selection, true
1580+ }
1581+ if selection.err != nil {
1582+ log.Warn("transient home picker failed; resuming parked route", "err", selection.err)
1583+ }
1584+ if err := sendParkedRouteSize(); err != nil {
1585+ return welcomedResult(err), true
1586+ }
1587+ if err := sendParkedRouteRequest(ports.ParkedRouteResume, leaseID, nil); err != nil {
1588+ return welcomedResult(fmt.Errorf("vev: resuming parked route: %w", err)), true
1589+ }
1590+ return attachResult{}, false
1591+ }
1592+ startForeground()
1593+ defer stopForeground()
1359 1594
1360 // 5. Output/main loop: the only goroutine that touches the terminal.1595 // 5. Output/main loop: the only goroutine that touches the terminal.
1361 recvCh := make(chan recvResult, 1)1596 recvCh := make(chan recvResult, 1)
1362- go runRecv(loopCtx, transport, recvCh, log)1597+ go runRecv(loopCtx, transport, recvCh, transportFailed, log)
1363 1598
1364 requestReconnectReset := func() error {1599 requestReconnectReset := func() error {
1365 if awaitingReconnectReset {1600 if awaitingReconnectReset {
@@ -1447,6 +1682,16 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1447 select {1682 select {
1448 case <-loopCtx.Done():1683 case <-loopCtx.Done():
1449 return loopCanceledResult()1684 return loopCanceledResult()
1685+ case <-transition.tickC():
1686+ if err := transition.advance(); err != nil {
1687+ return welcomedResult(err)
1688+ }
1689+ case <-parkedResponseC():
1690+ clearParkedPending()
1691+ return welcomedResult(errors.New("vev: timed out waiting for parked-route response"))
1692+ case <-parkedFullC():
1693+ clearParkedFull()
1694+ return welcomedResult(errors.New("vev: timed out waiting for parked-route full output"))
1450 case ev, ok := <-linkEvents:1695 case ev, ok := <-linkEvents:
1451 if !ok {1696 if !ok {
1452 linkEvents = nil1697 linkEvents = nil
@@ -1537,6 +1782,9 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1537 }1782 }
1538 continue1783 continue
1539 }1784 }
1785+ if parkedWaitingFull && !o.Full {
1786+ return welcomedResult(errors.New("vev: parked route resumed without an authoritative full output"))
1787+ }
1540 if _, werr := term.Out().Write(o.Data); werr != nil {1788 if _, werr := term.Out().Write(o.Data); werr != nil {
1541 return welcomedResult(fmt.Errorf("vev: writing terminal output: %w", werr))1789 return welcomedResult(fmt.Errorf("vev: writing terminal output: %w", werr))
1542 }1790 }
@@ -1555,6 +1803,14 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1555 return welcomedResult(fmt.Errorf("vev: flushing terminal: %w", ferr))1803 return welcomedResult(fmt.Errorf("vev: flushing terminal: %w", ferr))
1556 }1804 }
1557 outputState = nextState1805 outputState = nextState
1806+ if transition != nil && transition.active && (!transitionWaitingFull || o.Full) {
1807+ transitionWaitingFull = false
1808+ transition.stop()
1809+ }
1810+ if parkedWaitingFull {
1811+ clearParkedFull()
1812+ startForeground()
1813+ }
1558 if o.New != 0 {1814 if o.New != 0 {
1559 ackQueue.offer(o.Epoch, o.New)1815 ackQueue.offer(o.Epoch, o.New)
1560 outputResetRequested = false1816 outputResetRequested = false
@@ -1573,6 +1829,15 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1573 if derr != nil {1829 if derr != nil {
1574 return welcomedResult(fmt.Errorf("vev: decoding attach target: %w", derr))1830 return welcomedResult(fmt.Errorf("vev: decoding attach target: %w", derr))
1575 }1831 }
1832+ if a.returnAttachTargets {
1833+ handoff := welcomedResult(nil)
1834+ handoff.target = &target
1835+ return handoff
1836+ }
1837+ if transition != nil {
1838+ transitionWaitingFull = false
1839+ transition.start(target)
1840+ }
1576 if target.Endpoint == "" && target.RemoteTarget == nil && target.ExactTarget != nil && a.runner.ledger != nil {1841 if target.Endpoint == "" && target.RemoteTarget == nil && target.ExactTarget != nil && a.runner.ledger != nil {
1577 if samePeerSwitch != nil {1842 if samePeerSwitch != nil {
1578 return welcomedResult(errors.New("vev: concurrent same-peer switch offer"))1843 return welcomedResult(errors.New("vev: concurrent same-peer switch offer"))
@@ -1599,13 +1864,71 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1599 handoff := welcomedResult(nil)1864 handoff := welcomedResult(nil)
1600 handoff.target = &target1865 handoff.target = &target
1601 return handoff1866 return handoff
1867+ case ports.MsgParkedRouteResponse:
1868+ response, derr := ports.UnmarshalParkedRouteResponse(r.frame.Payload)
1869+ if derr != nil {
1870+ return welcomedResult(fmt.Errorf("vev: decoding parked-route response: %w", derr))
1871+ }
1872+ if parkedPending == nil || response.RequestID != parkedPending.requestID {
1873+ return welcomedResult(errors.New("vev: unexpected parked-route response"))
1874+ }
1875+ pending := *parkedPending
1876+ clearParkedPending()
1877+ switch pending.action {
1878+ case ports.ParkedRoutePrepare:
1879+ if response.Status != ports.ParkedRouteReady {
1880+ fallback := welcomedResult(nil)
1881+ fallback.action = ports.NavigationOpenHomePicker
1882+ return fallback
1883+ }
1884+ if selection, done := handleParkedPicker(pending.leaseID); done {
1885+ return selection
1886+ }
1887+ case ports.ParkedRouteResume:
1888+ if response.Status != ports.ParkedRouteResumed {
1889+ return welcomedResult(errors.New("vev: parked route could not resume"))
1890+ }
1891+ awaitParkedFull()
1892+ case ports.ParkedRouteSwitch:
1893+ switch response.Status {
1894+ case ports.ParkedRouteSwitched:
1895+ awaitParkedFull()
1896+ continue
1897+ case ports.ParkedRouteStaleTarget:
1898+ if transition != nil {
1899+ transition.stop()
1900+ }
1901+ if selection, done := handleParkedPicker(pending.leaseID); done {
1902+ return selection
1903+ }
1904+ default:
1905+ if pending.fallbackTarget == nil {
1906+ return welcomedResult(errors.New("vev: parked route switch failed"))
1907+ }
1908+ fallback := welcomedResult(nil)
1909+ fallback.target = pending.fallbackTarget
1910+ fallback.homePickerTarget = true
1911+ return fallback
1912+ }
1913+ }
1602 case ports.MsgNavigationAction:1914 case ports.MsgNavigationAction:
1603- action, derr := ports.UnmarshalNavigationAction(r.frame.Payload)1915+ directive, derr := ports.UnmarshalNavigationDirective(r.frame.Payload)
1604 if derr != nil {1916 if derr != nil {
1605 return welcomedResult(fmt.Errorf("vev: decoding navigation action: %w", derr))1917 return welcomedResult(fmt.Errorf("vev: decoding navigation action: %w", derr))
1606 }1918 }
1919+ _, datagramRoute := transport.(ports.DatagramTransport)
1920+ if directive.Action == ports.NavigationOpenHomePicker && a.openHomePicker != nil && datagramRoute {
1921+ stopForeground()
1922+ if err := flushSender(); err != nil {
1923+ return welcomedResult(fmt.Errorf("vev: parking remote foreground: %w", err))
1924+ }
1925+ if err := sendParkedRouteRequest(ports.ParkedRoutePrepare, directive.LeaseID, nil); err != nil {
1926+ return welcomedResult(fmt.Errorf("vev: preparing parked route: %w", err))
1927+ }
1928+ continue
1929+ }
1607 navigation := welcomedResult(nil)1930 navigation := welcomedResult(nil)
1608- navigation.action = action1931+ navigation.action = directive.Action
1609 return navigation1932 return navigation
1610 case ports.MsgNavigateRecentRoute:1933 case ports.MsgNavigateRecentRoute:
1611 action, derr := ports.UnmarshalRouteNavigationAction(r.frame.Payload)1934 action, derr := ports.UnmarshalRouteNavigationAction(r.frame.Payload)
@@ -1662,6 +1985,19 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1662 if samePeerSwitch != nil && failure.RequestID == samePeerSwitch.requestID {1985 if samePeerSwitch != nil && failure.RequestID == samePeerSwitch.requestID {
1663 samePeerSwitch = nil1986 samePeerSwitch = nil
1664 inputGate.setPaused(false)1987 inputGate.setPaused(false)
1988+ if transition != nil && transition.showing {
1989+ transitionWaitingFull = true
1990+ if !outputResetRequested {
1991+ select {
1992+ case sendCh <- ports.Frame{Type: ports.MsgOutputResetRequest, Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{})}:
1993+ outputResetRequested = true
1994+ case <-loopCtx.Done():
1995+ return loopCanceledResult()
1996+ }
1997+ }
1998+ } else if transition != nil {
1999+ transition.stop()
2000+ }
1665 }2001 }
1666 case ports.MsgRoutePosition:2002 case ports.MsgRoutePosition:
1667 position, derr := ports.UnmarshalRoutePosition(r.frame.Payload)2003 position, derr := ports.UnmarshalRoutePosition(r.frame.Payload)
@@ -1712,10 +2048,13 @@ type recvResult struct {
1712 2048
1713 // runRecv reads frames until an error, forwarding each to the main loop.2049 // runRecv reads frames until an error, forwarding each to the main loop.
1714 // It exits on the first error or when the loop context is cancelled.2050 // It exits on the first error or when the loop context is cancelled.
1715-func runRecv(ctx context.Context, transport ports.Transport, out chan<- recvResult, log *slog.Logger) {2051+func runRecv(ctx context.Context, transport ports.Transport, out chan<- recvResult, failed chan<- struct{}, log *slog.Logger) {
1716 defer log.Debug("receive pump exited")2052 defer log.Debug("receive pump exited")
1717 for {2053 for {
1718 f, err := transport.Recv()2054 f, err := transport.Recv()
2055+ if err != nil && failed != nil {
2056+ close(failed)
2057+ }
1719 select {2058 select {
1720 case out <- recvResult{frame: f, err: err}:2059 case out <- recvResult{frame: f, err: err}:
1721 case <-ctx.Done():2060 case <-ctx.Done():
@@ -1766,7 +2105,7 @@ func (q *cumulativeAckQueue) take() (uint64, uint64) {
1766 2105
1767 // runSender preserves normal-frame order while allowing switch control and2106 // runSender preserves normal-frame order while allowing switch control and
1768 // output ACKs to make progress when raw input is held for a same-peer switch.2107 // output ACKs to make progress when raw input is held for a same-peer switch.
1769-func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.Transport, control, in <-chan ports.Frame, inputGate *samePeerInputGate, acks *cumulativeAckQueue, errCh chan<- error, log *slog.Logger) {2108+func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.Transport, control <-chan ports.Frame, barriers <-chan chan struct{}, in <-chan ports.Frame, inputGate *samePeerInputGate, acks *cumulativeAckQueue, errCh chan<- error, log *slog.Logger) {
1770 defer log.Debug("sender pump exited")2109 defer log.Debug("sender pump exited")
1771 send := func(f ports.Frame) bool {2110 send := func(f ports.Frame) bool {
1772 if err := transport.Send(f); err != nil {2111 if err := transport.Send(f); err != nil {
@@ -1795,6 +2134,36 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T
1795 }2134 }
1796 return send(ports.Frame{Type: ports.MsgAck, Payload: payload})2135 return send(ports.Frame{Type: ports.MsgAck, Payload: payload})
1797 }2136 }
2137+ flushPending := func() bool {
2138+ for {
2139+ select {
2140+ case <-acks.wake:
2141+ if !sendAck() {
2142+ return false
2143+ }
2144+ continue
2145+ default:
2146+ }
2147+ if paused, _ := inputGate.snapshot(); paused {
2148+ return sendAck()
2149+ }
2150+ select {
2151+ case frame := <-in:
2152+ if !send(frame) {
2153+ return false
2154+ }
2155+ default:
2156+ return sendAck()
2157+ }
2158+ }
2159+ }
2160+ completeBarrier := func(done chan struct{}) bool {
2161+ if !flushPending() {
2162+ return false
2163+ }
2164+ close(done)
2165+ return true
2166+ }
1798 var heldInput *ports.Frame2167 var heldInput *ports.Frame
1799 for {2168 for {
1800 if heldInput != nil {2169 if heldInput != nil {
@@ -1815,6 +2184,10 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T
1815 if !send(frame) {2184 if !send(frame) {
1816 return2185 return
1817 }2186 }
2187+ case done := <-barriers:
2188+ if !completeBarrier(done) {
2189+ return
2190+ }
1818 case <-changed:2191 case <-changed:
1819 case <-ctx.Done():2192 case <-ctx.Done():
1820 return2193 return
@@ -1832,6 +2205,11 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T
1832 return2205 return
1833 }2206 }
1834 continue2207 continue
2208+ case done := <-barriers:
2209+ if !completeBarrier(done) {
2210+ return
2211+ }
2212+ continue
1835 default:2213 default:
1836 }2214 }
1837 select {2215 select {
@@ -1843,6 +2221,10 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T
1843 if !send(frame) {2221 if !send(frame) {
1844 return2222 return
1845 }2223 }
2224+ case done := <-barriers:
2225+ if !completeBarrier(done) {
2226+ return
2227+ }
1846 case frame := <-in:2228 case frame := <-in:
1847 select {2229 select {
1848 case <-ctx.Done():2230 case <-ctx.Done():
@@ -2185,6 +2567,36 @@ func (p *terminalInputPump) stop() {
2185 // preserving all ordinary terminal input byte-for-byte. It never reads the2567 // preserving all ordinary terminal input byte-for-byte. It never reads the
2186 // terminal directly, publishes a Theme, or writes terminal output;2568 // terminal directly, publishes a Theme, or writes terminal output;
2187 // attachAttempt owns those operations.2569 // attachAttempt owns those operations.
2570+type foregroundSendLease struct {
2571+ mu sync.Mutex
2572+ active bool
2573+}
2574+
2575+func newForegroundSendLease() *foregroundSendLease {
2576+ return &foregroundSendLease{active: true}
2577+}
2578+
2579+func (l *foregroundSendLease) send(send func() bool) bool {
2580+ if l == nil {
2581+ return send()
2582+ }
2583+ l.mu.Lock()
2584+ defer l.mu.Unlock()
2585+ if !l.active {
2586+ return false
2587+ }
2588+ return send()
2589+}
2590+
2591+func (l *foregroundSendLease) stop() {
2592+ if l == nil {
2593+ return
2594+ }
2595+ l.mu.Lock()
2596+ l.active = false
2597+ l.mu.Unlock()
2598+}
2599+
2188 type stdinPump struct {2600 type stdinPump struct {
2189 ctx context.Context2601 ctx context.Context
2190 cancel context.CancelFunc2602 cancel context.CancelFunc
@@ -2200,6 +2612,7 @@ type stdinPump struct {
2200 afterPaletteEvent func(paletteGenerationEvent) // test synchronization hook2612 afterPaletteEvent func(paletteGenerationEvent) // test synchronization hook
2201 afterInputTake func() // test synchronization hook2613 afterInputTake func() // test synchronization hook
2202 afterInputDelivered func() // test synchronization hook2614 afterInputDelivered func() // test synchronization hook
2615+ sendLease *foregroundSendLease
2203 }2616 }
2204 2617
2205 func (p *stdinPump) run() {2618 func (p *stdinPump) run() {
@@ -2215,31 +2628,35 @@ func (p *stdinPump) run() {
2215 if !sendOK.Load() {2628 if !sendOK.Load() {
2216 return false2629 return false
2217 }2630 }
2218- select {2631+ return p.sendLease.send(func() bool {
2219- case p.out <- frame:2632+ select {
2220- if p.afterInputDelivered != nil {2633+ case p.out <- frame:
2221- p.afterInputDelivered()2634+ if p.afterInputDelivered != nil {
2635+ p.afterInputDelivered()
2636+ }
2637+ return true
2638+ case <-p.ctx.Done():
2639+ sendOK.Store(false)
2640+ return false
2222 }2641 }
2223- return true2642+ })
2224- case <-p.ctx.Done():
2225- sendOK.Store(false)
2226- return false
2227- }
2228 }2643 }
2229 sendEvent := func(event paletteGenerationEvent) bool {2644 sendEvent := func(event paletteGenerationEvent) bool {
2230 if !sendOK.Load() {2645 if !sendOK.Load() {
2231 return false2646 return false
2232 }2647 }
2233- select {2648+ return p.sendLease.send(func() bool {
2234- case p.paletteEvents <- event:2649+ select {
2235- if p.afterPaletteEvent != nil {2650+ case p.paletteEvents <- event:
2236- p.afterPaletteEvent(event)2651+ if p.afterPaletteEvent != nil {
2652+ p.afterPaletteEvent(event)
2653+ }
2654+ return true
2655+ case <-p.ctx.Done():
2656+ sendOK.Store(false)
2657+ return false
2237 }2658 }
2238- return true2659+ })
2239- case <-p.ctx.Done():
2240- sendOK.Store(false)
2241- return false
2242- }
2243 }2660 }
2244 coalescer := newPasteCoalescer(p.clock, func(data []byte) {2661 coalescer := newPasteCoalescer(p.clock, func(data []byte) {
2245 if len(data) == 0 {2662 if len(data) == 0 {
@@ -2427,7 +2844,7 @@ func (p *stdinPump) run() {
2427 // runResize forwards coalesced terminal resize events to the daemon. It2844 // runResize forwards coalesced terminal resize events to the daemon. It
2428 // tolerates an already-closed resize channel (which the terminal adapter2845 // tolerates an already-closed resize channel (which the terminal adapter
2429 // hands back when restore ran before ResizeEvents was first called).2846 // hands back when restore ran before ResizeEvents was first called).
2430-func runResize(ctx context.Context, events <-chan domain.Size, out chan<- ports.Frame, log *slog.Logger) {2847+func runResize(ctx context.Context, events <-chan domain.Size, out chan<- ports.Frame, sendLease *foregroundSendLease, log *slog.Logger) {
2431 defer log.Debug("resize pump exited")2848 defer log.Debug("resize pump exited")
2432 for {2849 for {
2433 select {2850 select {
@@ -2441,9 +2858,14 @@ func runResize(ctx context.Context, events <-chan domain.Size, out chan<- ports.
2441 continue2858 continue
2442 }2859 }
2443 frame := ports.Frame{Type: ports.MsgResize, Payload: payload}2860 frame := ports.Frame{Type: ports.MsgResize, Payload: payload}
2444- select {2861+ if !sendLease.send(func() bool {
2445- case out <- frame:2862+ select {
2446- case <-ctx.Done():2863+ case out <- frame:
2864+ return true
2865+ case <-ctx.Done():
2866+ return false
2867+ }
2868+ }) {
2447 return2869 return
2448 }2870 }
2449 case <-ctx.Done():2871 case <-ctx.Done():
M internal/usecase/client/client_test.go +542 -12
@@ -56,13 +56,18 @@ func (c *reconnectTestClock) NewTimer(d time.Duration) ports.Timer {
5656 }
5757
5858 func (c *reconnectTestClock) fireReconnect(t *testing.T) {
59+ t.Helper()
60+ c.fireDuration(t, 100*time.Millisecond)
61+}
62+
63+func (c *reconnectTestClock) fireDuration(t *testing.T, duration time.Duration) {
5964 t.Helper()
6065 require.Eventually(t, func() bool {
6166 c.mu.Lock()
6267 defer c.mu.Unlock()
6368 for _, timer := range c.timers {
6469 timer.mu.Lock()
65- if timer.duration != 100*time.Millisecond {
70+ if timer.duration != duration {
6671 timer.mu.Unlock()
6772 continue
6873 }
@@ -111,8 +116,9 @@ func (t *reconnectTestTimer) Stop() bool {
111116
112117 // recvItem is one scripted Recv result.
113118 type recvItem struct {
114- f ports.Frame
115- err error
119+ f ports.Frame
120+ err error
121+ wait <-chan struct{}
116122 }
117123
118124 type markedDatagramTransport struct{ ports.Transport }
@@ -131,6 +137,13 @@ func scriptRecv(tr *portsmocks.MockTransport, items ...recvItem) func() {
131137 tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
132138 select {
133139 case it := <-ch:
140+ if it.wait != nil {
141+ select {
142+ case <-it.wait:
143+ case <-done:
144+ return ports.Frame{}, io.EOF
145+ }
146+ }
134147 return it.f, it.err
135148 case <-done:
136149 return ports.Frame{}, io.EOF
@@ -552,7 +565,7 @@ func TestAcceptedHomeActionUsesCapturedRouteAfterRequestMetadataRebase(t *testin
552565 }}
553566 remote := &recordingTransport{recvs: []recvItem{
554567 {f: welcome("work", remoteLifecycle)},
555- {f: frameOf(ports.MsgNavigationAction, ports.MarshalNavigationAction(ports.NavigationOpenHomePicker))},
568+ {f: navigationDirectiveFrame(ports.NavigationOpenHomePicker)},
556569 }}
557570 local2 := &recordingTransport{recvs: []recvItem{
558571 {f: welcome("local", localLifecycle)},
@@ -578,6 +591,485 @@ func TestAcceptedHomeActionUsesCapturedRouteAfterRequestMetadataRebase(t *testin
578591 require.Equal(t, ports.StartupOverlaySessionPicker, helloFromSend(t, local2).StartupOverlay)
579592 }
580593
594+func TestHybridPickerSameHostSwitchReusesRemoteTransport(t *testing.T) {
595+ term := newRunTerminal()
596+ defer term.in.unblock()
597+
598+ localLifecycle := domain.SessionLifecycleID{1}
599+ remoteSourceLifecycle := domain.SessionLifecycleID{2}
600+ remoteTargetLifecycle := domain.SessionLifecycleID{3}
601+ welcome := func(name string, lifecycle domain.SessionLifecycleID) ports.Frame {
602+ return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{
603+ SessionID: name, SessionName: name, ResumeToken: 1, Capabilities: ports.CapabilityResume,
604+ CommittedIdentity: &ports.CommittedRouteIdentity{Target: ports.ExactSessionTarget{LifecycleID: lifecycle, SessionName: name}},
605+ }))
606+ }
607+ sourceTarget := domain.RemoteSessionTarget{
608+ Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: remoteSourceLifecycle,
609+ SessionName: "source", LiveTabID: "source-tab",
610+ }
611+ targetTarget := domain.RemoteSessionTarget{
612+ Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: remoteTargetLifecycle,
613+ SessionName: "target", LiveTabID: "target-tab",
614+ }
615+ localInitial := &recordingTransport{recvs: []recvItem{
616+ {f: welcome("local", localLifecycle)},
617+ {f: frameOf(ports.MsgAttachTarget, ports.MarshalAttachTarget(ports.AttachTarget{
618+ Endpoint: "remote", Session: "source", Intent: ports.IntentAttach,
619+ RemoteTarget: &sourceTarget, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
620+ }))},
621+ }}
622+ parkSent := make(chan struct{})
623+ switchSent := make(chan struct{})
624+ parkedRequests := make(chan ports.ParkedRouteRequest, 2)
625+ parkedRequestErrors := make(chan error, 2)
626+ var parkOnce, switchOnce sync.Once
627+ remote := &recordingTransport{recvs: []recvItem{
628+ {f: welcome("source", remoteSourceLifecycle)},
629+ {f: navigationDirectiveFrame(ports.NavigationOpenHomePicker)},
630+ {f: frameOf(ports.MsgParkedRouteResponse, ports.MarshalParkedRouteResponse(ports.ParkedRouteResponse{RequestID: 1, Status: ports.ParkedRouteReady})), wait: parkSent},
631+ {f: frameOf(ports.MsgCommittedRouteIdentity, mustMarshalCommittedIdentity(ports.CommittedRouteIdentity{Target: ports.ExactSessionTarget{LifecycleID: remoteTargetLifecycle, SessionName: "target"}})), wait: switchSent},
632+ {f: frameOf(ports.MsgParkedRouteResponse, ports.MarshalParkedRouteResponse(ports.ParkedRouteResponse{RequestID: 2, Status: ports.ParkedRouteSwitched}))},
633+ {f: frameOf(ports.MsgOutput, mustMarshalOutput(ports.Output{Epoch: 2, New: 1, Size: domain.Size{Cols: 80, Rows: 24}, Full: true, Data: []byte("target frame")}))},
634+ {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
635+ }}
636+ remote.onSend = func(frame ports.Frame) {
637+ if frame.Type != ports.MsgParkedRouteRequest {
638+ return
639+ }
640+ request, err := ports.UnmarshalParkedRouteRequest(frame.Payload)
641+ if err != nil {
642+ parkedRequestErrors <- err
643+ return
644+ }
645+ parkedRequests <- request
646+ switch request.Action {
647+ case ports.ParkedRoutePrepare:
648+ parkOnce.Do(func() { close(parkSent) })
649+ case ports.ParkedRouteSwitch:
650+ switchOnce.Do(func() { close(switchSent) })
651+ }
652+ }
653+ localPicker := &recordingTransport{recvs: []recvItem{
654+ {f: welcome("local", localLifecycle)},
655+ {f: frameOf(ports.MsgAttachTarget, ports.MarshalAttachTarget(ports.AttachTarget{
656+ Endpoint: "remote", Session: "target", Intent: ports.IntentAttach,
657+ RemoteTarget: &targetTarget, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
658+ }))},
659+ }}
660+ var resizeOnce sync.Once
661+ localPicker.onSend = func(frame ports.Frame) {
662+ if frame.Type == ports.MsgHello {
663+ resizeOnce.Do(func() { term.setSize(domain.Size{Cols: 100, Rows: 40}) })
664+ }
665+ }
666+ localDialer := &sequenceDialer{trs: []ports.Transport{localInitial, localPicker}}
667+ remoteDialer := &sequenceDialer{trs: []ports.Transport{markedDatagramTransport{Transport: remote}}}
668+ deps := testDependencies(localDialer, term, realClock{}, nil, nil)
669+ deps.AttachHandoff = func(target ports.AttachTarget) (ports.Dialer, client.AttachRequest, error) {
670+ selection := *target.RemoteTarget
671+ return remoteDialer, client.AttachRequest{
672+ Intent: ports.IntentAttach, SessionName: target.Session, Remote: true,
673+ Origin: ports.RouteOriginDiscovery, OriginKey: target.Endpoint,
674+ RemoteTarget: &selection, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
675+ }, nil
676+ }
677+
678+ err := runTestClient(context.Background(), deps, client.AttachRequest{
679+ Intent: ports.IntentAttach, SessionName: "local", Origin: ports.RouteOriginLocal, OriginKey: "local",
680+ })
681+
682+ require.NoError(t, err)
683+ select {
684+ case requestErr := <-parkedRequestErrors:
685+ require.NoError(t, requestErr)
686+ default:
687+ }
688+ prepareRequest := <-parkedRequests
689+ switchRequest := <-parkedRequests
690+ require.Equal(t, ports.ParkedRoutePrepare, prepareRequest.Action)
691+ require.Equal(t, ports.ParkedRouteSwitch, switchRequest.Action)
692+ require.NotNil(t, switchRequest.Target)
693+ require.Equal(t, targetTarget, *switchRequest.Target)
694+ frames := remote.Sends()
695+ switchIndex := -1
696+ for i, frame := range frames {
697+ if frame.Type != ports.MsgParkedRouteRequest {
698+ continue
699+ }
700+ request, requestErr := ports.UnmarshalParkedRouteRequest(frame.Payload)
701+ require.NoError(t, requestErr)
702+ if request.Action == ports.ParkedRouteSwitch {
703+ switchIndex = i
704+ break
705+ }
706+ }
707+ require.Positive(t, switchIndex)
708+ require.Equal(t, ports.MsgResize, frames[switchIndex-1].Type, "current size must precede the parked switch")
709+ resize, resizeErr := ports.UnmarshalResize(frames[switchIndex-1].Payload)
710+ require.NoError(t, resizeErr)
711+ require.Equal(t, domain.Size{Cols: 100, Rows: 40}, resize.Size)
712+ require.Equal(t, int32(1), remoteDialer.calls.Load(), "same-host picker switch must retain the authenticated remote transport")
713+}
714+
715+func TestHybridPickerExpiredSwitchFallsBackToNewDial(t *testing.T) {
716+ term := newRunTerminal()
717+ defer term.in.unblock()
718+
719+ localLifecycle := domain.SessionLifecycleID{1}
720+ sourceLifecycle := domain.SessionLifecycleID{2}
721+ targetLifecycle := domain.SessionLifecycleID{3}
722+ welcome := func(name string, lifecycle domain.SessionLifecycleID) ports.Frame {
723+ return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{
724+ SessionID: name, SessionName: name, ResumeToken: 1, Capabilities: ports.CapabilityResume,
725+ CommittedIdentity: &ports.CommittedRouteIdentity{Target: ports.ExactSessionTarget{LifecycleID: lifecycle, SessionName: name}},
726+ }))
727+ }
728+ sourceTarget := domain.RemoteSessionTarget{
729+ Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: sourceLifecycle,
730+ SessionName: "source", LiveTabID: "source-tab",
731+ }
732+ targetTarget := domain.RemoteSessionTarget{
733+ Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: targetLifecycle,
734+ SessionName: "target", LiveTabID: "target-tab",
735+ }
736+ localInitial := &recordingTransport{recvs: []recvItem{
737+ {f: welcome("local", localLifecycle)},
738+ {f: frameOf(ports.MsgAttachTarget, ports.MarshalAttachTarget(ports.AttachTarget{
739+ Endpoint: "remote", Session: "source", Intent: ports.IntentAttach,
740+ RemoteTarget: &sourceTarget, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
741+ }))},
742+ }}
743+ prepareSent := make(chan struct{})
744+ switchSent := make(chan struct{})
745+ var prepareOnce, switchOnce sync.Once
746+ sourceRemote := &recordingTransport{recvs: []recvItem{
747+ {f: welcome("source", sourceLifecycle)},
748+ {f: navigationDirectiveFrame(ports.NavigationOpenHomePicker)},
749+ {f: frameOf(ports.MsgParkedRouteResponse, ports.MarshalParkedRouteResponse(ports.ParkedRouteResponse{RequestID: 1, Status: ports.ParkedRouteReady})), wait: prepareSent},
750+ {f: frameOf(ports.MsgParkedRouteResponse, ports.MarshalParkedRouteResponse(ports.ParkedRouteResponse{RequestID: 2, Status: ports.ParkedRouteExpired})), wait: switchSent},
751+ }}
752+ sourceRemote.onSend = func(frame ports.Frame) {
753+ if frame.Type != ports.MsgParkedRouteRequest {
754+ return
755+ }
756+ request, err := ports.UnmarshalParkedRouteRequest(frame.Payload)
757+ if err != nil {
758+ return
759+ }
760+ switch request.Action {
761+ case ports.ParkedRoutePrepare:
762+ prepareOnce.Do(func() { close(prepareSent) })
763+ case ports.ParkedRouteSwitch:
764+ switchOnce.Do(func() { close(switchSent) })
765+ }
766+ }
767+ localPicker := &recordingTransport{recvs: []recvItem{
768+ {f: welcome("local", localLifecycle)},
769+ {f: frameOf(ports.MsgAttachTarget, ports.MarshalAttachTarget(ports.AttachTarget{
770+ Endpoint: "remote", Session: "target", Intent: ports.IntentAttach,
771+ RemoteTarget: &targetTarget, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
772+ }))},
773+ }}
774+ targetRemote := &recordingTransport{recvs: []recvItem{
775+ {f: welcome("target", targetLifecycle)},
776+ {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
777+ }}
778+ localDialer := &sequenceDialer{trs: []ports.Transport{localInitial, localPicker}}
779+ remoteDialer := &sequenceDialer{trs: []ports.Transport{
780+ markedDatagramTransport{Transport: sourceRemote}, targetRemote,
781+ }}
782+ deps := testDependencies(localDialer, term, realClock{}, nil, nil)
783+ deps.AttachHandoff = func(target ports.AttachTarget) (ports.Dialer, client.AttachRequest, error) {
784+ selection := *target.RemoteTarget
785+ return remoteDialer, client.AttachRequest{
786+ Intent: ports.IntentAttach, SessionName: target.Session, Remote: true,
787+ Origin: ports.RouteOriginDiscovery, OriginKey: target.Endpoint,
788+ RemoteTarget: &selection, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
789+ }, nil
790+ }
791+
792+ require.NoError(t, runTestClient(context.Background(), deps, client.AttachRequest{
793+ Intent: ports.IntentAttach, SessionName: "local", Origin: ports.RouteOriginLocal, OriginKey: "local",
794+ }))
795+ require.Equal(t, int32(2), remoteDialer.calls.Load(), "an expired parked lease must use the traditional dial path")
796+}
797+
798+func TestHybridPickerPrepareResponseTimeoutClosesRetainedTransport(t *testing.T) {
799+ term := newRunTerminal()
800+ defer term.in.unblock()
801+ clock := portsmocks.NewMockClock(t)
802+ deadlineReady := make(chan chan time.Time, 1)
803+ var timerCount atomic.Int32
804+ clock.EXPECT().Now().Return(time.Time{}).Maybe()
805+ clock.EXPECT().NewTimer(mock.Anything).RunAndReturn(func(duration time.Duration) ports.Timer {
806+ timer := portsmocks.NewMockTimer(t)
807+ ch := make(chan time.Time, 1)
808+ timer.EXPECT().C().Return(ch).Maybe()
809+ timer.EXPECT().Reset(mock.Anything).Return(true).Maybe()
810+ timer.EXPECT().Stop().Return(true).Maybe()
811+ if duration == ports.HandshakeTimeout && timerCount.Add(1) == 3 {
812+ deadlineReady <- ch
813+ }
814+ return timer
815+ }).Maybe()
816+
817+ localLifecycle := domain.SessionLifecycleID{1}
818+ remoteLifecycle := domain.SessionLifecycleID{2}
819+ welcome := func(name string, lifecycle domain.SessionLifecycleID) ports.Frame {
820+ return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{
821+ SessionID: name, SessionName: name, ResumeToken: 1, Capabilities: ports.CapabilityResume,
822+ CommittedIdentity: &ports.CommittedRouteIdentity{Target: ports.ExactSessionTarget{LifecycleID: lifecycle, SessionName: name}},
823+ }))
824+ }
825+ remoteTarget := domain.RemoteSessionTarget{
826+ Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: remoteLifecycle,
827+ SessionName: "work", LiveTabID: "tab-1",
828+ }
829+ localInitial := &recordingTransport{recvs: []recvItem{
830+ {f: welcome("local", localLifecycle)},
831+ {f: frameOf(ports.MsgAttachTarget, ports.MarshalAttachTarget(ports.AttachTarget{
832+ Endpoint: "remote", Session: "work", Intent: ports.IntentAttach,
833+ RemoteTarget: &remoteTarget, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
834+ }))},
835+ }}
836+ prepareSent := make(chan struct{})
837+ remoteClosed := make(chan struct{})
838+ var prepareOnce, closeOnce sync.Once
839+ remote := &recordingTransport{
840+ recvs: []recvItem{
841+ {f: welcome("work", remoteLifecycle)},
842+ {f: navigationDirectiveFrame(ports.NavigationOpenHomePicker)},
843+ },
844+ stall: remoteClosed,
845+ }
846+ remote.onSend = func(frame ports.Frame) {
847+ if frame.Type != ports.MsgParkedRouteRequest {
848+ return
849+ }
850+ request, err := ports.UnmarshalParkedRouteRequest(frame.Payload)
851+ if err == nil && request.Action == ports.ParkedRoutePrepare {
852+ prepareOnce.Do(func() { close(prepareSent) })
853+ }
854+ }
855+ remote.onClose = func() { closeOnce.Do(func() { close(remoteClosed) }) }
856+
857+ localDialer := &sequenceDialer{trs: []ports.Transport{localInitial}}
858+ remoteDialer := &sequenceDialer{trs: []ports.Transport{markedDatagramTransport{Transport: remote}}}
859+ deps := testDependencies(localDialer, term, clock, nil, nil)
860+ deps.AttachHandoff = func(target ports.AttachTarget) (ports.Dialer, client.AttachRequest, error) {
861+ selection := *target.RemoteTarget
862+ return remoteDialer, client.AttachRequest{
863+ Intent: ports.IntentAttach, SessionName: target.Session, Remote: true,
864+ Origin: ports.RouteOriginDiscovery, OriginKey: target.Endpoint,
865+ RemoteTarget: &selection, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
866+ }, nil
867+ }
868+
869+ ctx, cancel := context.WithCancel(context.Background())
870+ defer cancel()
871+ remote.onClose = func() {
872+ closeOnce.Do(func() { close(remoteClosed) })
873+ cancel()
874+ }
875+ result := make(chan error, 1)
876+ go func() {
877+ result <- runTestClient(ctx, deps, client.AttachRequest{
878+ Intent: ports.IntentAttach, SessionName: "local", Origin: ports.RouteOriginLocal, OriginKey: "local",
879+ })
880+ }()
881+ select {
882+ case <-prepareSent:
883+ case <-time.After(time.Second):
884+ t.Fatal("timed out waiting for parked Prepare request")
885+ }
886+ var deadline chan time.Time
887+ select {
888+ case deadline = <-deadlineReady:
889+ case <-time.After(time.Second):
890+ t.Fatal("timed out waiting for parked response deadline")
891+ }
892+ deadline <- time.Time{}
893+ select {
894+ case err := <-result:
895+ require.ErrorContains(t, err, "timed out waiting for parked-route response")
896+ case <-time.After(time.Second):
897+ t.Fatal("client did not leave the parked request after its deadline")
898+ }
899+ require.Positive(t, remote.closed.Load())
900+}
901+
902+func TestHybridPickerBackResumesRetainedRemoteTransport(t *testing.T) {
903+ term := newRunTerminal()
904+ defer term.in.unblock()
905+
906+ localLifecycle := domain.SessionLifecycleID{1}
907+ remoteLifecycle := domain.SessionLifecycleID{2}
908+ welcome := func(name string, lifecycle domain.SessionLifecycleID) ports.Frame {
909+ return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{
910+ SessionID: name, SessionName: name, ResumeToken: 1, Capabilities: ports.CapabilityResume,
911+ CommittedIdentity: &ports.CommittedRouteIdentity{Target: ports.ExactSessionTarget{LifecycleID: lifecycle, SessionName: name}},
912+ }))
913+ }
914+ remoteTarget := domain.RemoteSessionTarget{
915+ Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: remoteLifecycle,
916+ SessionName: "work", LiveTabID: "tab-1",
917+ }
918+ localInitial := &recordingTransport{recvs: []recvItem{
919+ {f: welcome("local", localLifecycle)},
920+ {f: frameOf(ports.MsgAttachTarget, ports.MarshalAttachTarget(ports.AttachTarget{
921+ Endpoint: "remote", Session: "work", Intent: ports.IntentAttach,
922+ RemoteTarget: &remoteTarget, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
923+ }))},
924+ }}
925+ parkSent := make(chan struct{})
926+ resumeSent := make(chan struct{})
927+ parkedRequestErrors := make(chan error, 2)
928+ var parkOnce, resumeOnce sync.Once
929+ remote := &recordingTransport{recvs: []recvItem{
930+ {f: welcome("work", remoteLifecycle)},
931+ {f: navigationDirectiveFrame(ports.NavigationOpenHomePicker)},
932+ {f: frameOf(ports.MsgParkedRouteResponse, ports.MarshalParkedRouteResponse(ports.ParkedRouteResponse{RequestID: 1, Status: ports.ParkedRouteReady})), wait: parkSent},
933+ {f: frameOf(ports.MsgParkedRouteResponse, ports.MarshalParkedRouteResponse(ports.ParkedRouteResponse{RequestID: 2, Status: ports.ParkedRouteResumed})), wait: resumeSent},
934+ {f: frameOf(ports.MsgOutput, mustMarshalOutput(ports.Output{Epoch: 1, New: 1, Size: domain.Size{Cols: 80, Rows: 24}, Full: true, Data: []byte("source frame")}))},
935+ {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
936+ }}
937+ remote.onSend = func(frame ports.Frame) {
938+ if frame.Type != ports.MsgParkedRouteRequest {
939+ return
940+ }
941+ request, err := ports.UnmarshalParkedRouteRequest(frame.Payload)
942+ if err != nil {
943+ parkedRequestErrors <- err
944+ return
945+ }
946+ switch request.Action {
947+ case ports.ParkedRoutePrepare:
948+ parkOnce.Do(func() { close(parkSent) })
949+ case ports.ParkedRouteResume:
950+ resumeOnce.Do(func() { close(resumeSent) })
951+ }
952+ }
953+ localPicker := &recordingTransport{recvs: []recvItem{
954+ {f: welcome("local", localLifecycle)},
955+ {f: navigationDirectiveFrame(ports.NavigationBack)},
956+ }}
957+ localDialer := &sequenceDialer{trs: []ports.Transport{localInitial, localPicker}}
958+ remoteDialer := &sequenceDialer{trs: []ports.Transport{markedDatagramTransport{Transport: remote}}}
959+ deps := testDependencies(localDialer, term, realClock{}, nil, nil)
960+ deps.AttachHandoff = func(target ports.AttachTarget) (ports.Dialer, client.AttachRequest, error) {
961+ selection := *target.RemoteTarget
962+ return remoteDialer, client.AttachRequest{
963+ Intent: ports.IntentAttach, SessionName: target.Session, Remote: true,
964+ Origin: ports.RouteOriginDiscovery, OriginKey: target.Endpoint,
965+ RemoteTarget: &selection, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
966+ }, nil
967+ }
968+
969+ require.NoError(t, runTestClient(context.Background(), deps, client.AttachRequest{
970+ Intent: ports.IntentAttach, SessionName: "local", Origin: ports.RouteOriginLocal, OriginKey: "local",
971+ }))
972+ select {
973+ case requestErr := <-parkedRequestErrors:
974+ require.NoError(t, requestErr)
975+ default:
976+ }
977+ require.Equal(t, int32(1), remoteDialer.calls.Load())
978+}
979+
980+func TestHybridPickerDifferentHostFallsBackToNewRemoteDial(t *testing.T) {
981+ term := newRunTerminal()
982+ defer term.in.unblock()
983+
984+ localLifecycle := domain.SessionLifecycleID{1}
985+ sourceLifecycle := domain.SessionLifecycleID{2}
986+ targetLifecycle := domain.SessionLifecycleID{3}
987+ welcome := func(name string, lifecycle domain.SessionLifecycleID) ports.Frame {
988+ return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{
989+ SessionID: name, SessionName: name, ResumeToken: 1, Capabilities: ports.CapabilityResume,
990+ CommittedIdentity: &ports.CommittedRouteIdentity{Target: ports.ExactSessionTarget{LifecycleID: lifecycle, SessionName: name}},
991+ }))
992+ }
993+ sourceTarget := domain.RemoteSessionTarget{
994+ Endpoint: "source-host", DisplayOrigin: "source-host", LifecycleID: sourceLifecycle,
995+ SessionName: "source", LiveTabID: "source-tab",
996+ }
997+ targetTarget := domain.RemoteSessionTarget{
998+ Endpoint: "target-host", DisplayOrigin: "target-host", LifecycleID: targetLifecycle,
999+ SessionName: "target", LiveTabID: "target-tab",
1000+ }
1001+ localInitial := &recordingTransport{recvs: []recvItem{
1002+ {f: welcome("local", localLifecycle)},
1003+ {f: frameOf(ports.MsgAttachTarget, ports.MarshalAttachTarget(ports.AttachTarget{
1004+ Endpoint: sourceTarget.Endpoint, Session: sourceTarget.SessionName, Intent: ports.IntentAttach,
1005+ RemoteTarget: &sourceTarget, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
1006+ }))},
1007+ }}
1008+ parkSent := make(chan struct{})
1009+ keepSourceOpen := make(chan struct{})
1010+ parkedRequestErrors := make(chan error, 1)
1011+ defer close(keepSourceOpen)
1012+ var parkOnce sync.Once
1013+ sourceRemote := &recordingTransport{recvs: []recvItem{
1014+ {f: welcome("source", sourceLifecycle)},
1015+ {f: navigationDirectiveFrame(ports.NavigationOpenHomePicker)},
1016+ {f: frameOf(ports.MsgParkedRouteResponse, ports.MarshalParkedRouteResponse(ports.ParkedRouteResponse{RequestID: 1, Status: ports.ParkedRouteReady})), wait: parkSent},
1017+ {err: io.EOF, wait: keepSourceOpen},
1018+ }}
1019+ sourceRemote.onSend = func(frame ports.Frame) {
1020+ if frame.Type != ports.MsgParkedRouteRequest {
1021+ return
1022+ }
1023+ request, err := ports.UnmarshalParkedRouteRequest(frame.Payload)
1024+ if err != nil {
1025+ parkedRequestErrors <- err
1026+ return
1027+ }
1028+ if request.Action == ports.ParkedRoutePrepare {
1029+ parkOnce.Do(func() { close(parkSent) })
1030+ }
1031+ }
1032+ localPicker := &recordingTransport{recvs: []recvItem{
1033+ {f: welcome("local", localLifecycle)},
1034+ {f: frameOf(ports.MsgAttachTarget, ports.MarshalAttachTarget(ports.AttachTarget{
1035+ Endpoint: targetTarget.Endpoint, Session: targetTarget.SessionName, Intent: ports.IntentAttach,
1036+ RemoteTarget: &targetTarget, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
1037+ }))},
1038+ }}
1039+ targetRemote := &recordingTransport{recvs: []recvItem{
1040+ {f: welcome("target", targetLifecycle)},
1041+ {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
1042+ }}
1043+ localDialer := &sequenceDialer{trs: []ports.Transport{localInitial, localPicker}}
1044+ sourceDialer := &sequenceDialer{trs: []ports.Transport{markedDatagramTransport{Transport: sourceRemote}}}
1045+ targetDialer := &sequenceDialer{trs: []ports.Transport{targetRemote}}
1046+ deps := testDependencies(localDialer, term, realClock{}, nil, nil)
1047+ deps.AttachHandoff = func(target ports.AttachTarget) (ports.Dialer, client.AttachRequest, error) {
1048+ selection := *target.RemoteTarget
1049+ next := sourceDialer
1050+ if target.Endpoint == targetTarget.Endpoint {
1051+ next = targetDialer
1052+ }
1053+ return next, client.AttachRequest{
1054+ Intent: ports.IntentAttach, SessionName: target.Session, Remote: true,
1055+ Origin: ports.RouteOriginDiscovery, OriginKey: target.Endpoint,
1056+ RemoteTarget: &selection, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
1057+ }, nil
1058+ }
1059+
1060+ require.NoError(t, runTestClient(context.Background(), deps, client.AttachRequest{
1061+ Intent: ports.IntentAttach, SessionName: "local", Origin: ports.RouteOriginLocal, OriginKey: "local",
1062+ }))
1063+ select {
1064+ case requestErr := <-parkedRequestErrors:
1065+ require.NoError(t, requestErr)
1066+ default:
1067+ }
1068+ require.Equal(t, int32(1), sourceDialer.calls.Load())
1069+ require.Equal(t, int32(1), targetDialer.calls.Load())
1070+ require.Positive(t, sourceRemote.closed.Load())
1071+}
1072+
5811073 func TestRouteNavigationPreservesRemoteHomePickerAcrossLocalReturn(t *testing.T) {
5821074 term := newRunTerminal()
5831075 defer term.in.unblock()
@@ -622,7 +1114,7 @@ func TestRouteNavigationPreservesRemoteHomePickerAcrossLocalReturn(t *testing.T)
6221114 }}
6231115 local3 := &recordingTransport{recvs: []recvItem{
6241116 {f: welcome("test", localLifecycle, 11)},
625- {f: frameOf(ports.MsgNavigationAction, ports.MarshalNavigationAction(ports.NavigationBack))},
1117+ {f: navigationDirectiveFrame(ports.NavigationBack)},
6261118 }}
6271119 remote1 := &recordingTransport{recvs: []recvItem{
6281120 {f: welcome("remote-manual", remoteLifecycle, 22)},
@@ -636,7 +1128,7 @@ func TestRouteNavigationPreservesRemoteHomePickerAcrossLocalReturn(t *testing.T)
6361128 {f: frameOf(ports.MsgRoutePosition, mustMarshalRoutePosition(ports.RoutePosition{
6371129 Target: ports.ExactSessionTarget{LifecycleID: remoteNewLifecycle, SessionName: "remote-new"}, ActiveTabID: "tab-2",
6381130 }))},
639- {f: frameOf(ports.MsgNavigationAction, ports.MarshalNavigationAction(ports.NavigationOpenHomePicker))},
1131+ {f: navigationDirectiveFrame(ports.NavigationOpenHomePicker)},
6401132 }}
6411133 remote3 := &recordingTransport{recvs: []recvItem{
6421134 {f: welcome("remote-new", remoteNewLifecycle, 22)},
@@ -667,6 +1159,14 @@ func TestRouteNavigationPreservesRemoteHomePickerAcrossLocalReturn(t *testing.T)
6671159 require.Equal(t, &ports.ExactSessionTarget{LifecycleID: remoteNewLifecycle, SessionName: "remote-new"}, remoteNewHello.ExactTarget)
6681160 }
6691161
1162+func navigationDirectiveFrame(action ports.NavigationAction) ports.Frame {
1163+ directive := ports.NavigationDirective{Action: action}
1164+ if action == ports.NavigationOpenHomePicker {
1165+ directive.LeaseID = ports.ParkedRouteLeaseID{1}
1166+ }
1167+ return frameOf(ports.MsgNavigationAction, ports.MarshalNavigationDirective(directive))
1168+}
1169+
6701170 func mustMarshalCommittedIdentity(identity ports.CommittedRouteIdentity) []byte {
6711171 payload, err := ports.MarshalCommittedRouteIdentity(identity)
6721172 if err != nil {
@@ -1132,16 +1632,23 @@ func (d *sequenceDialer) Dial(context.Context) (ports.Transport, error) {
11321632 }
11331633
11341634 type recordingTransport struct {
1135- mu sync.Mutex
1136- recvs []recvItem
1137- sends []ports.Frame
1138- closed atomic.Int32
1635+ mu sync.Mutex
1636+ recvs []recvItem
1637+ sends []ports.Frame
1638+ closed atomic.Int32
1639+ stall <-chan struct{}
1640+ onSend func(ports.Frame)
1641+ onClose func()
11391642 }
11401643
11411644 func (t *recordingTransport) Send(f ports.Frame) error {
11421645 t.mu.Lock()
11431646 t.sends = append(t.sends, f)
1647+ onSend := t.onSend
11441648 t.mu.Unlock()
1649+ if onSend != nil {
1650+ onSend(f)
1651+ }
11451652 return nil
11461653 }
11471654
@@ -1153,15 +1660,24 @@ func (t *recordingTransport) Sends() []ports.Frame {
11531660
11541661 func (t *recordingTransport) Recv() (ports.Frame, error) {
11551662 if len(t.recvs) == 0 {
1663+ if t.stall != nil {
1664+ <-t.stall
1665+ }
11561666 return ports.Frame{}, io.EOF
11571667 }
11581668 it := t.recvs[0]
11591669 t.recvs = t.recvs[1:]
1670+ if it.wait != nil {
1671+ <-it.wait
1672+ }
11601673 return it.f, it.err
11611674 }
11621675
11631676 func (t *recordingTransport) Close() error {
11641677 t.closed.Add(1)
1678+ if t.onClose != nil {
1679+ t.onClose()
1680+ }
11651681 return nil
11661682 }
11671683
@@ -1171,17 +1687,31 @@ type runTerminal struct {
11711687 rawCount atomic.Int32
11721688 restoreCount atomic.Int32
11731689 resizeCh chan domain.Size
1690+ sizeMu sync.Mutex
1691+ size domain.Size
11741692 }
11751693
11761694 func newRunTerminal() *runTerminal {
1177- return &runTerminal{in: newBlockingReader(), resizeCh: make(chan domain.Size)}
1695+ return &runTerminal{
1696+ in: newBlockingReader(), resizeCh: make(chan domain.Size),
1697+ size: domain.Size{Cols: 80, Rows: 24},
1698+ }
11781699 }
11791700
11801701 func (t *runTerminal) EnterRaw() (func() error, error) {
11811702 t.rawCount.Add(1)
11821703 return func() error { t.restoreCount.Add(1); return nil }, nil
11831704 }
1184-func (t *runTerminal) Size() (domain.Size, error) { return domain.Size{Cols: 80, Rows: 24}, nil }
1705+func (t *runTerminal) Size() (domain.Size, error) {
1706+ t.sizeMu.Lock()
1707+ defer t.sizeMu.Unlock()
1708+ return t.size, nil
1709+}
1710+func (t *runTerminal) setSize(size domain.Size) {
1711+ t.sizeMu.Lock()
1712+ t.size = size
1713+ t.sizeMu.Unlock()
1714+}
11851715 func (t *runTerminal) ResizeEvents() <-chan domain.Size { return t.resizeCh }
11861716 func (t *runTerminal) In() io.Reader { return t.in }
11871717 func (t *runTerminal) Out() io.Writer { return &t.out }
@@ -56,13 +56,18 @@ func (c *reconnectTestClock) NewTimer(d time.Duration) ports.Timer {
56 }56 }
57 57
58 func (c *reconnectTestClock) fireReconnect(t *testing.T) {58 func (c *reconnectTestClock) fireReconnect(t *testing.T) {
59+ t.Helper()
60+ c.fireDuration(t, 100*time.Millisecond)
61+}
62+
63+func (c *reconnectTestClock) fireDuration(t *testing.T, duration time.Duration) {
59 t.Helper()64 t.Helper()
60 require.Eventually(t, func() bool {65 require.Eventually(t, func() bool {
61 c.mu.Lock()66 c.mu.Lock()
62 defer c.mu.Unlock()67 defer c.mu.Unlock()
63 for _, timer := range c.timers {68 for _, timer := range c.timers {
64 timer.mu.Lock()69 timer.mu.Lock()
65- if timer.duration != 100*time.Millisecond {70+ if timer.duration != duration {
66 timer.mu.Unlock()71 timer.mu.Unlock()
67 continue72 continue
68 }73 }
@@ -111,8 +116,9 @@ func (t *reconnectTestTimer) Stop() bool {
111 116
112 // recvItem is one scripted Recv result.117 // recvItem is one scripted Recv result.
113 type recvItem struct {118 type recvItem struct {
114- f ports.Frame119+ f ports.Frame
115- err error120+ err error
121+ wait <-chan struct{}
116 }122 }
117 123
118 type markedDatagramTransport struct{ ports.Transport }124 type markedDatagramTransport struct{ ports.Transport }
@@ -131,6 +137,13 @@ func scriptRecv(tr *portsmocks.MockTransport, items ...recvItem) func() {
131 tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {137 tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
132 select {138 select {
133 case it := <-ch:139 case it := <-ch:
140+ if it.wait != nil {
141+ select {
142+ case <-it.wait:
143+ case <-done:
144+ return ports.Frame{}, io.EOF
145+ }
146+ }
134 return it.f, it.err147 return it.f, it.err
135 case <-done:148 case <-done:
136 return ports.Frame{}, io.EOF149 return ports.Frame{}, io.EOF
@@ -552,7 +565,7 @@ func TestAcceptedHomeActionUsesCapturedRouteAfterRequestMetadataRebase(t *testin
552 }}565 }}
553 remote := &recordingTransport{recvs: []recvItem{566 remote := &recordingTransport{recvs: []recvItem{
554 {f: welcome("work", remoteLifecycle)},567 {f: welcome("work", remoteLifecycle)},
555- {f: frameOf(ports.MsgNavigationAction, ports.MarshalNavigationAction(ports.NavigationOpenHomePicker))},568+ {f: navigationDirectiveFrame(ports.NavigationOpenHomePicker)},
556 }}569 }}
557 local2 := &recordingTransport{recvs: []recvItem{570 local2 := &recordingTransport{recvs: []recvItem{
558 {f: welcome("local", localLifecycle)},571 {f: welcome("local", localLifecycle)},
@@ -578,6 +591,485 @@ func TestAcceptedHomeActionUsesCapturedRouteAfterRequestMetadataRebase(t *testin
578 require.Equal(t, ports.StartupOverlaySessionPicker, helloFromSend(t, local2).StartupOverlay)591 require.Equal(t, ports.StartupOverlaySessionPicker, helloFromSend(t, local2).StartupOverlay)
579 }592 }
580 593
594+func TestHybridPickerSameHostSwitchReusesRemoteTransport(t *testing.T) {
595+ term := newRunTerminal()
596+ defer term.in.unblock()
597+
598+ localLifecycle := domain.SessionLifecycleID{1}
599+ remoteSourceLifecycle := domain.SessionLifecycleID{2}
600+ remoteTargetLifecycle := domain.SessionLifecycleID{3}
601+ welcome := func(name string, lifecycle domain.SessionLifecycleID) ports.Frame {
602+ return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{
603+ SessionID: name, SessionName: name, ResumeToken: 1, Capabilities: ports.CapabilityResume,
604+ CommittedIdentity: &ports.CommittedRouteIdentity{Target: ports.ExactSessionTarget{LifecycleID: lifecycle, SessionName: name}},
605+ }))
606+ }
607+ sourceTarget := domain.RemoteSessionTarget{
608+ Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: remoteSourceLifecycle,
609+ SessionName: "source", LiveTabID: "source-tab",
610+ }
611+ targetTarget := domain.RemoteSessionTarget{
612+ Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: remoteTargetLifecycle,
613+ SessionName: "target", LiveTabID: "target-tab",
614+ }
615+ localInitial := &recordingTransport{recvs: []recvItem{
616+ {f: welcome("local", localLifecycle)},
617+ {f: frameOf(ports.MsgAttachTarget, ports.MarshalAttachTarget(ports.AttachTarget{
618+ Endpoint: "remote", Session: "source", Intent: ports.IntentAttach,
619+ RemoteTarget: &sourceTarget, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
620+ }))},
621+ }}
622+ parkSent := make(chan struct{})
623+ switchSent := make(chan struct{})
624+ parkedRequests := make(chan ports.ParkedRouteRequest, 2)
625+ parkedRequestErrors := make(chan error, 2)
626+ var parkOnce, switchOnce sync.Once
627+ remote := &recordingTransport{recvs: []recvItem{
628+ {f: welcome("source", remoteSourceLifecycle)},
629+ {f: navigationDirectiveFrame(ports.NavigationOpenHomePicker)},
630+ {f: frameOf(ports.MsgParkedRouteResponse, ports.MarshalParkedRouteResponse(ports.ParkedRouteResponse{RequestID: 1, Status: ports.ParkedRouteReady})), wait: parkSent},
631+ {f: frameOf(ports.MsgCommittedRouteIdentity, mustMarshalCommittedIdentity(ports.CommittedRouteIdentity{Target: ports.ExactSessionTarget{LifecycleID: remoteTargetLifecycle, SessionName: "target"}})), wait: switchSent},
632+ {f: frameOf(ports.MsgParkedRouteResponse, ports.MarshalParkedRouteResponse(ports.ParkedRouteResponse{RequestID: 2, Status: ports.ParkedRouteSwitched}))},
633+ {f: frameOf(ports.MsgOutput, mustMarshalOutput(ports.Output{Epoch: 2, New: 1, Size: domain.Size{Cols: 80, Rows: 24}, Full: true, Data: []byte("target frame")}))},
634+ {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
635+ }}
636+ remote.onSend = func(frame ports.Frame) {
637+ if frame.Type != ports.MsgParkedRouteRequest {
638+ return
639+ }
640+ request, err := ports.UnmarshalParkedRouteRequest(frame.Payload)
641+ if err != nil {
642+ parkedRequestErrors <- err
643+ return
644+ }
645+ parkedRequests <- request
646+ switch request.Action {
647+ case ports.ParkedRoutePrepare:
648+ parkOnce.Do(func() { close(parkSent) })
649+ case ports.ParkedRouteSwitch:
650+ switchOnce.Do(func() { close(switchSent) })
651+ }
652+ }
653+ localPicker := &recordingTransport{recvs: []recvItem{
654+ {f: welcome("local", localLifecycle)},
655+ {f: frameOf(ports.MsgAttachTarget, ports.MarshalAttachTarget(ports.AttachTarget{
656+ Endpoint: "remote", Session: "target", Intent: ports.IntentAttach,
657+ RemoteTarget: &targetTarget, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
658+ }))},
659+ }}
660+ var resizeOnce sync.Once
661+ localPicker.onSend = func(frame ports.Frame) {
662+ if frame.Type == ports.MsgHello {
663+ resizeOnce.Do(func() { term.setSize(domain.Size{Cols: 100, Rows: 40}) })
664+ }
665+ }
666+ localDialer := &sequenceDialer{trs: []ports.Transport{localInitial, localPicker}}
667+ remoteDialer := &sequenceDialer{trs: []ports.Transport{markedDatagramTransport{Transport: remote}}}
668+ deps := testDependencies(localDialer, term, realClock{}, nil, nil)
669+ deps.AttachHandoff = func(target ports.AttachTarget) (ports.Dialer, client.AttachRequest, error) {
670+ selection := *target.RemoteTarget
671+ return remoteDialer, client.AttachRequest{
672+ Intent: ports.IntentAttach, SessionName: target.Session, Remote: true,
673+ Origin: ports.RouteOriginDiscovery, OriginKey: target.Endpoint,
674+ RemoteTarget: &selection, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
675+ }, nil
676+ }
677+
678+ err := runTestClient(context.Background(), deps, client.AttachRequest{
679+ Intent: ports.IntentAttach, SessionName: "local", Origin: ports.RouteOriginLocal, OriginKey: "local",
680+ })
681+
682+ require.NoError(t, err)
683+ select {
684+ case requestErr := <-parkedRequestErrors:
685+ require.NoError(t, requestErr)
686+ default:
687+ }
688+ prepareRequest := <-parkedRequests
689+ switchRequest := <-parkedRequests
690+ require.Equal(t, ports.ParkedRoutePrepare, prepareRequest.Action)
691+ require.Equal(t, ports.ParkedRouteSwitch, switchRequest.Action)
692+ require.NotNil(t, switchRequest.Target)
693+ require.Equal(t, targetTarget, *switchRequest.Target)
694+ frames := remote.Sends()
695+ switchIndex := -1
696+ for i, frame := range frames {
697+ if frame.Type != ports.MsgParkedRouteRequest {
698+ continue
699+ }
700+ request, requestErr := ports.UnmarshalParkedRouteRequest(frame.Payload)
701+ require.NoError(t, requestErr)
702+ if request.Action == ports.ParkedRouteSwitch {
703+ switchIndex = i
704+ break
705+ }
706+ }
707+ require.Positive(t, switchIndex)
708+ require.Equal(t, ports.MsgResize, frames[switchIndex-1].Type, "current size must precede the parked switch")
709+ resize, resizeErr := ports.UnmarshalResize(frames[switchIndex-1].Payload)
710+ require.NoError(t, resizeErr)
711+ require.Equal(t, domain.Size{Cols: 100, Rows: 40}, resize.Size)
712+ require.Equal(t, int32(1), remoteDialer.calls.Load(), "same-host picker switch must retain the authenticated remote transport")
713+}
714+
715+func TestHybridPickerExpiredSwitchFallsBackToNewDial(t *testing.T) {
716+ term := newRunTerminal()
717+ defer term.in.unblock()
718+
719+ localLifecycle := domain.SessionLifecycleID{1}
720+ sourceLifecycle := domain.SessionLifecycleID{2}
721+ targetLifecycle := domain.SessionLifecycleID{3}
722+ welcome := func(name string, lifecycle domain.SessionLifecycleID) ports.Frame {
723+ return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{
724+ SessionID: name, SessionName: name, ResumeToken: 1, Capabilities: ports.CapabilityResume,
725+ CommittedIdentity: &ports.CommittedRouteIdentity{Target: ports.ExactSessionTarget{LifecycleID: lifecycle, SessionName: name}},
726+ }))
727+ }
728+ sourceTarget := domain.RemoteSessionTarget{
729+ Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: sourceLifecycle,
730+ SessionName: "source", LiveTabID: "source-tab",
731+ }
732+ targetTarget := domain.RemoteSessionTarget{
733+ Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: targetLifecycle,
734+ SessionName: "target", LiveTabID: "target-tab",
735+ }
736+ localInitial := &recordingTransport{recvs: []recvItem{
737+ {f: welcome("local", localLifecycle)},
738+ {f: frameOf(ports.MsgAttachTarget, ports.MarshalAttachTarget(ports.AttachTarget{
739+ Endpoint: "remote", Session: "source", Intent: ports.IntentAttach,
740+ RemoteTarget: &sourceTarget, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
741+ }))},
742+ }}
743+ prepareSent := make(chan struct{})
744+ switchSent := make(chan struct{})
745+ var prepareOnce, switchOnce sync.Once
746+ sourceRemote := &recordingTransport{recvs: []recvItem{
747+ {f: welcome("source", sourceLifecycle)},
748+ {f: navigationDirectiveFrame(ports.NavigationOpenHomePicker)},
749+ {f: frameOf(ports.MsgParkedRouteResponse, ports.MarshalParkedRouteResponse(ports.ParkedRouteResponse{RequestID: 1, Status: ports.ParkedRouteReady})), wait: prepareSent},
750+ {f: frameOf(ports.MsgParkedRouteResponse, ports.MarshalParkedRouteResponse(ports.ParkedRouteResponse{RequestID: 2, Status: ports.ParkedRouteExpired})), wait: switchSent},
751+ }}
752+ sourceRemote.onSend = func(frame ports.Frame) {
753+ if frame.Type != ports.MsgParkedRouteRequest {
754+ return
755+ }
756+ request, err := ports.UnmarshalParkedRouteRequest(frame.Payload)
757+ if err != nil {
758+ return
759+ }
760+ switch request.Action {
761+ case ports.ParkedRoutePrepare:
762+ prepareOnce.Do(func() { close(prepareSent) })
763+ case ports.ParkedRouteSwitch:
764+ switchOnce.Do(func() { close(switchSent) })
765+ }
766+ }
767+ localPicker := &recordingTransport{recvs: []recvItem{
768+ {f: welcome("local", localLifecycle)},
769+ {f: frameOf(ports.MsgAttachTarget, ports.MarshalAttachTarget(ports.AttachTarget{
770+ Endpoint: "remote", Session: "target", Intent: ports.IntentAttach,
771+ RemoteTarget: &targetTarget, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
772+ }))},
773+ }}
774+ targetRemote := &recordingTransport{recvs: []recvItem{
775+ {f: welcome("target", targetLifecycle)},
776+ {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
777+ }}
778+ localDialer := &sequenceDialer{trs: []ports.Transport{localInitial, localPicker}}
779+ remoteDialer := &sequenceDialer{trs: []ports.Transport{
780+ markedDatagramTransport{Transport: sourceRemote}, targetRemote,
781+ }}
782+ deps := testDependencies(localDialer, term, realClock{}, nil, nil)
783+ deps.AttachHandoff = func(target ports.AttachTarget) (ports.Dialer, client.AttachRequest, error) {
784+ selection := *target.RemoteTarget
785+ return remoteDialer, client.AttachRequest{
786+ Intent: ports.IntentAttach, SessionName: target.Session, Remote: true,
787+ Origin: ports.RouteOriginDiscovery, OriginKey: target.Endpoint,
788+ RemoteTarget: &selection, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
789+ }, nil
790+ }
791+
792+ require.NoError(t, runTestClient(context.Background(), deps, client.AttachRequest{
793+ Intent: ports.IntentAttach, SessionName: "local", Origin: ports.RouteOriginLocal, OriginKey: "local",
794+ }))
795+ require.Equal(t, int32(2), remoteDialer.calls.Load(), "an expired parked lease must use the traditional dial path")
796+}
797+
798+func TestHybridPickerPrepareResponseTimeoutClosesRetainedTransport(t *testing.T) {
799+ term := newRunTerminal()
800+ defer term.in.unblock()
801+ clock := portsmocks.NewMockClock(t)
802+ deadlineReady := make(chan chan time.Time, 1)
803+ var timerCount atomic.Int32
804+ clock.EXPECT().Now().Return(time.Time{}).Maybe()
805+ clock.EXPECT().NewTimer(mock.Anything).RunAndReturn(func(duration time.Duration) ports.Timer {
806+ timer := portsmocks.NewMockTimer(t)
807+ ch := make(chan time.Time, 1)
808+ timer.EXPECT().C().Return(ch).Maybe()
809+ timer.EXPECT().Reset(mock.Anything).Return(true).Maybe()
810+ timer.EXPECT().Stop().Return(true).Maybe()
811+ if duration == ports.HandshakeTimeout && timerCount.Add(1) == 3 {
812+ deadlineReady <- ch
813+ }
814+ return timer
815+ }).Maybe()
816+
817+ localLifecycle := domain.SessionLifecycleID{1}
818+ remoteLifecycle := domain.SessionLifecycleID{2}
819+ welcome := func(name string, lifecycle domain.SessionLifecycleID) ports.Frame {
820+ return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{
821+ SessionID: name, SessionName: name, ResumeToken: 1, Capabilities: ports.CapabilityResume,
822+ CommittedIdentity: &ports.CommittedRouteIdentity{Target: ports.ExactSessionTarget{LifecycleID: lifecycle, SessionName: name}},
823+ }))
824+ }
825+ remoteTarget := domain.RemoteSessionTarget{
826+ Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: remoteLifecycle,
827+ SessionName: "work", LiveTabID: "tab-1",
828+ }
829+ localInitial := &recordingTransport{recvs: []recvItem{
830+ {f: welcome("local", localLifecycle)},
831+ {f: frameOf(ports.MsgAttachTarget, ports.MarshalAttachTarget(ports.AttachTarget{
832+ Endpoint: "remote", Session: "work", Intent: ports.IntentAttach,
833+ RemoteTarget: &remoteTarget, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
834+ }))},
835+ }}
836+ prepareSent := make(chan struct{})
837+ remoteClosed := make(chan struct{})
838+ var prepareOnce, closeOnce sync.Once
839+ remote := &recordingTransport{
840+ recvs: []recvItem{
841+ {f: welcome("work", remoteLifecycle)},
842+ {f: navigationDirectiveFrame(ports.NavigationOpenHomePicker)},
843+ },
844+ stall: remoteClosed,
845+ }
846+ remote.onSend = func(frame ports.Frame) {
847+ if frame.Type != ports.MsgParkedRouteRequest {
848+ return
849+ }
850+ request, err := ports.UnmarshalParkedRouteRequest(frame.Payload)
851+ if err == nil && request.Action == ports.ParkedRoutePrepare {
852+ prepareOnce.Do(func() { close(prepareSent) })
853+ }
854+ }
855+ remote.onClose = func() { closeOnce.Do(func() { close(remoteClosed) }) }
856+
857+ localDialer := &sequenceDialer{trs: []ports.Transport{localInitial}}
858+ remoteDialer := &sequenceDialer{trs: []ports.Transport{markedDatagramTransport{Transport: remote}}}
859+ deps := testDependencies(localDialer, term, clock, nil, nil)
860+ deps.AttachHandoff = func(target ports.AttachTarget) (ports.Dialer, client.AttachRequest, error) {
861+ selection := *target.RemoteTarget
862+ return remoteDialer, client.AttachRequest{
863+ Intent: ports.IntentAttach, SessionName: target.Session, Remote: true,
864+ Origin: ports.RouteOriginDiscovery, OriginKey: target.Endpoint,
865+ RemoteTarget: &selection, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
866+ }, nil
867+ }
868+
869+ ctx, cancel := context.WithCancel(context.Background())
870+ defer cancel()
871+ remote.onClose = func() {
872+ closeOnce.Do(func() { close(remoteClosed) })
873+ cancel()
874+ }
875+ result := make(chan error, 1)
876+ go func() {
877+ result <- runTestClient(ctx, deps, client.AttachRequest{
878+ Intent: ports.IntentAttach, SessionName: "local", Origin: ports.RouteOriginLocal, OriginKey: "local",
879+ })
880+ }()
881+ select {
882+ case <-prepareSent:
883+ case <-time.After(time.Second):
884+ t.Fatal("timed out waiting for parked Prepare request")
885+ }
886+ var deadline chan time.Time
887+ select {
888+ case deadline = <-deadlineReady:
889+ case <-time.After(time.Second):
890+ t.Fatal("timed out waiting for parked response deadline")
891+ }
892+ deadline <- time.Time{}
893+ select {
894+ case err := <-result:
895+ require.ErrorContains(t, err, "timed out waiting for parked-route response")
896+ case <-time.After(time.Second):
897+ t.Fatal("client did not leave the parked request after its deadline")
898+ }
899+ require.Positive(t, remote.closed.Load())
900+}
901+
902+func TestHybridPickerBackResumesRetainedRemoteTransport(t *testing.T) {
903+ term := newRunTerminal()
904+ defer term.in.unblock()
905+
906+ localLifecycle := domain.SessionLifecycleID{1}
907+ remoteLifecycle := domain.SessionLifecycleID{2}
908+ welcome := func(name string, lifecycle domain.SessionLifecycleID) ports.Frame {
909+ return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{
910+ SessionID: name, SessionName: name, ResumeToken: 1, Capabilities: ports.CapabilityResume,
911+ CommittedIdentity: &ports.CommittedRouteIdentity{Target: ports.ExactSessionTarget{LifecycleID: lifecycle, SessionName: name}},
912+ }))
913+ }
914+ remoteTarget := domain.RemoteSessionTarget{
915+ Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: remoteLifecycle,
916+ SessionName: "work", LiveTabID: "tab-1",
917+ }
918+ localInitial := &recordingTransport{recvs: []recvItem{
919+ {f: welcome("local", localLifecycle)},
920+ {f: frameOf(ports.MsgAttachTarget, ports.MarshalAttachTarget(ports.AttachTarget{
921+ Endpoint: "remote", Session: "work", Intent: ports.IntentAttach,
922+ RemoteTarget: &remoteTarget, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
923+ }))},
924+ }}
925+ parkSent := make(chan struct{})
926+ resumeSent := make(chan struct{})
927+ parkedRequestErrors := make(chan error, 2)
928+ var parkOnce, resumeOnce sync.Once
929+ remote := &recordingTransport{recvs: []recvItem{
930+ {f: welcome("work", remoteLifecycle)},
931+ {f: navigationDirectiveFrame(ports.NavigationOpenHomePicker)},
932+ {f: frameOf(ports.MsgParkedRouteResponse, ports.MarshalParkedRouteResponse(ports.ParkedRouteResponse{RequestID: 1, Status: ports.ParkedRouteReady})), wait: parkSent},
933+ {f: frameOf(ports.MsgParkedRouteResponse, ports.MarshalParkedRouteResponse(ports.ParkedRouteResponse{RequestID: 2, Status: ports.ParkedRouteResumed})), wait: resumeSent},
934+ {f: frameOf(ports.MsgOutput, mustMarshalOutput(ports.Output{Epoch: 1, New: 1, Size: domain.Size{Cols: 80, Rows: 24}, Full: true, Data: []byte("source frame")}))},
935+ {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
936+ }}
937+ remote.onSend = func(frame ports.Frame) {
938+ if frame.Type != ports.MsgParkedRouteRequest {
939+ return
940+ }
941+ request, err := ports.UnmarshalParkedRouteRequest(frame.Payload)
942+ if err != nil {
943+ parkedRequestErrors <- err
944+ return
945+ }
946+ switch request.Action {
947+ case ports.ParkedRoutePrepare:
948+ parkOnce.Do(func() { close(parkSent) })
949+ case ports.ParkedRouteResume:
950+ resumeOnce.Do(func() { close(resumeSent) })
951+ }
952+ }
953+ localPicker := &recordingTransport{recvs: []recvItem{
954+ {f: welcome("local", localLifecycle)},
955+ {f: navigationDirectiveFrame(ports.NavigationBack)},
956+ }}
957+ localDialer := &sequenceDialer{trs: []ports.Transport{localInitial, localPicker}}
958+ remoteDialer := &sequenceDialer{trs: []ports.Transport{markedDatagramTransport{Transport: remote}}}
959+ deps := testDependencies(localDialer, term, realClock{}, nil, nil)
960+ deps.AttachHandoff = func(target ports.AttachTarget) (ports.Dialer, client.AttachRequest, error) {
961+ selection := *target.RemoteTarget
962+ return remoteDialer, client.AttachRequest{
963+ Intent: ports.IntentAttach, SessionName: target.Session, Remote: true,
964+ Origin: ports.RouteOriginDiscovery, OriginKey: target.Endpoint,
965+ RemoteTarget: &selection, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
966+ }, nil
967+ }
968+
969+ require.NoError(t, runTestClient(context.Background(), deps, client.AttachRequest{
970+ Intent: ports.IntentAttach, SessionName: "local", Origin: ports.RouteOriginLocal, OriginKey: "local",
971+ }))
972+ select {
973+ case requestErr := <-parkedRequestErrors:
974+ require.NoError(t, requestErr)
975+ default:
976+ }
977+ require.Equal(t, int32(1), remoteDialer.calls.Load())
978+}
979+
980+func TestHybridPickerDifferentHostFallsBackToNewRemoteDial(t *testing.T) {
981+ term := newRunTerminal()
982+ defer term.in.unblock()
983+
984+ localLifecycle := domain.SessionLifecycleID{1}
985+ sourceLifecycle := domain.SessionLifecycleID{2}
986+ targetLifecycle := domain.SessionLifecycleID{3}
987+ welcome := func(name string, lifecycle domain.SessionLifecycleID) ports.Frame {
988+ return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{
989+ SessionID: name, SessionName: name, ResumeToken: 1, Capabilities: ports.CapabilityResume,
990+ CommittedIdentity: &ports.CommittedRouteIdentity{Target: ports.ExactSessionTarget{LifecycleID: lifecycle, SessionName: name}},
991+ }))
992+ }
993+ sourceTarget := domain.RemoteSessionTarget{
994+ Endpoint: "source-host", DisplayOrigin: "source-host", LifecycleID: sourceLifecycle,
995+ SessionName: "source", LiveTabID: "source-tab",
996+ }
997+ targetTarget := domain.RemoteSessionTarget{
998+ Endpoint: "target-host", DisplayOrigin: "target-host", LifecycleID: targetLifecycle,
999+ SessionName: "target", LiveTabID: "target-tab",
1000+ }
1001+ localInitial := &recordingTransport{recvs: []recvItem{
1002+ {f: welcome("local", localLifecycle)},
1003+ {f: frameOf(ports.MsgAttachTarget, ports.MarshalAttachTarget(ports.AttachTarget{
1004+ Endpoint: sourceTarget.Endpoint, Session: sourceTarget.SessionName, Intent: ports.IntentAttach,
1005+ RemoteTarget: &sourceTarget, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
1006+ }))},
1007+ }}
1008+ parkSent := make(chan struct{})
1009+ keepSourceOpen := make(chan struct{})
1010+ parkedRequestErrors := make(chan error, 1)
1011+ defer close(keepSourceOpen)
1012+ var parkOnce sync.Once
1013+ sourceRemote := &recordingTransport{recvs: []recvItem{
1014+ {f: welcome("source", sourceLifecycle)},
1015+ {f: navigationDirectiveFrame(ports.NavigationOpenHomePicker)},
1016+ {f: frameOf(ports.MsgParkedRouteResponse, ports.MarshalParkedRouteResponse(ports.ParkedRouteResponse{RequestID: 1, Status: ports.ParkedRouteReady})), wait: parkSent},
1017+ {err: io.EOF, wait: keepSourceOpen},
1018+ }}
1019+ sourceRemote.onSend = func(frame ports.Frame) {
1020+ if frame.Type != ports.MsgParkedRouteRequest {
1021+ return
1022+ }
1023+ request, err := ports.UnmarshalParkedRouteRequest(frame.Payload)
1024+ if err != nil {
1025+ parkedRequestErrors <- err
1026+ return
1027+ }
1028+ if request.Action == ports.ParkedRoutePrepare {
1029+ parkOnce.Do(func() { close(parkSent) })
1030+ }
1031+ }
1032+ localPicker := &recordingTransport{recvs: []recvItem{
1033+ {f: welcome("local", localLifecycle)},
1034+ {f: frameOf(ports.MsgAttachTarget, ports.MarshalAttachTarget(ports.AttachTarget{
1035+ Endpoint: targetTarget.Endpoint, Session: targetTarget.SessionName, Intent: ports.IntentAttach,
1036+ RemoteTarget: &targetTarget, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
1037+ }))},
1038+ }}
1039+ targetRemote := &recordingTransport{recvs: []recvItem{
1040+ {f: welcome("target", targetLifecycle)},
1041+ {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
1042+ }}
1043+ localDialer := &sequenceDialer{trs: []ports.Transport{localInitial, localPicker}}
1044+ sourceDialer := &sequenceDialer{trs: []ports.Transport{markedDatagramTransport{Transport: sourceRemote}}}
1045+ targetDialer := &sequenceDialer{trs: []ports.Transport{targetRemote}}
1046+ deps := testDependencies(localDialer, term, realClock{}, nil, nil)
1047+ deps.AttachHandoff = func(target ports.AttachTarget) (ports.Dialer, client.AttachRequest, error) {
1048+ selection := *target.RemoteTarget
1049+ next := sourceDialer
1050+ if target.Endpoint == targetTarget.Endpoint {
1051+ next = targetDialer
1052+ }
1053+ return next, client.AttachRequest{
1054+ Intent: ports.IntentAttach, SessionName: target.Session, Remote: true,
1055+ Origin: ports.RouteOriginDiscovery, OriginKey: target.Endpoint,
1056+ RemoteTarget: &selection, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
1057+ }, nil
1058+ }
1059+
1060+ require.NoError(t, runTestClient(context.Background(), deps, client.AttachRequest{
1061+ Intent: ports.IntentAttach, SessionName: "local", Origin: ports.RouteOriginLocal, OriginKey: "local",
1062+ }))
1063+ select {
1064+ case requestErr := <-parkedRequestErrors:
1065+ require.NoError(t, requestErr)
1066+ default:
1067+ }
1068+ require.Equal(t, int32(1), sourceDialer.calls.Load())
1069+ require.Equal(t, int32(1), targetDialer.calls.Load())
1070+ require.Positive(t, sourceRemote.closed.Load())
1071+}
1072+
581 func TestRouteNavigationPreservesRemoteHomePickerAcrossLocalReturn(t *testing.T) {1073 func TestRouteNavigationPreservesRemoteHomePickerAcrossLocalReturn(t *testing.T) {
582 term := newRunTerminal()1074 term := newRunTerminal()
583 defer term.in.unblock()1075 defer term.in.unblock()
@@ -622,7 +1114,7 @@ func TestRouteNavigationPreservesRemoteHomePickerAcrossLocalReturn(t *testing.T)
622 }}1114 }}
623 local3 := &recordingTransport{recvs: []recvItem{1115 local3 := &recordingTransport{recvs: []recvItem{
624 {f: welcome("test", localLifecycle, 11)},1116 {f: welcome("test", localLifecycle, 11)},
625- {f: frameOf(ports.MsgNavigationAction, ports.MarshalNavigationAction(ports.NavigationBack))},1117+ {f: navigationDirectiveFrame(ports.NavigationBack)},
626 }}1118 }}
627 remote1 := &recordingTransport{recvs: []recvItem{1119 remote1 := &recordingTransport{recvs: []recvItem{
628 {f: welcome("remote-manual", remoteLifecycle, 22)},1120 {f: welcome("remote-manual", remoteLifecycle, 22)},
@@ -636,7 +1128,7 @@ func TestRouteNavigationPreservesRemoteHomePickerAcrossLocalReturn(t *testing.T)
636 {f: frameOf(ports.MsgRoutePosition, mustMarshalRoutePosition(ports.RoutePosition{1128 {f: frameOf(ports.MsgRoutePosition, mustMarshalRoutePosition(ports.RoutePosition{
637 Target: ports.ExactSessionTarget{LifecycleID: remoteNewLifecycle, SessionName: "remote-new"}, ActiveTabID: "tab-2",1129 Target: ports.ExactSessionTarget{LifecycleID: remoteNewLifecycle, SessionName: "remote-new"}, ActiveTabID: "tab-2",
638 }))},1130 }))},
639- {f: frameOf(ports.MsgNavigationAction, ports.MarshalNavigationAction(ports.NavigationOpenHomePicker))},1131+ {f: navigationDirectiveFrame(ports.NavigationOpenHomePicker)},
640 }}1132 }}
641 remote3 := &recordingTransport{recvs: []recvItem{1133 remote3 := &recordingTransport{recvs: []recvItem{
642 {f: welcome("remote-new", remoteNewLifecycle, 22)},1134 {f: welcome("remote-new", remoteNewLifecycle, 22)},
@@ -667,6 +1159,14 @@ func TestRouteNavigationPreservesRemoteHomePickerAcrossLocalReturn(t *testing.T)
667 require.Equal(t, &ports.ExactSessionTarget{LifecycleID: remoteNewLifecycle, SessionName: "remote-new"}, remoteNewHello.ExactTarget)1159 require.Equal(t, &ports.ExactSessionTarget{LifecycleID: remoteNewLifecycle, SessionName: "remote-new"}, remoteNewHello.ExactTarget)
668 }1160 }
669 1161
1162+func navigationDirectiveFrame(action ports.NavigationAction) ports.Frame {
1163+ directive := ports.NavigationDirective{Action: action}
1164+ if action == ports.NavigationOpenHomePicker {
1165+ directive.LeaseID = ports.ParkedRouteLeaseID{1}
1166+ }
1167+ return frameOf(ports.MsgNavigationAction, ports.MarshalNavigationDirective(directive))
1168+}
1169+
670 func mustMarshalCommittedIdentity(identity ports.CommittedRouteIdentity) []byte {1170 func mustMarshalCommittedIdentity(identity ports.CommittedRouteIdentity) []byte {
671 payload, err := ports.MarshalCommittedRouteIdentity(identity)1171 payload, err := ports.MarshalCommittedRouteIdentity(identity)
672 if err != nil {1172 if err != nil {
@@ -1132,16 +1632,23 @@ func (d *sequenceDialer) Dial(context.Context) (ports.Transport, error) {
1132 }1632 }
1133 1633
1134 type recordingTransport struct {1634 type recordingTransport struct {
1135- mu sync.Mutex1635+ mu sync.Mutex
1136- recvs []recvItem1636+ recvs []recvItem
1137- sends []ports.Frame1637+ sends []ports.Frame
1138- closed atomic.Int321638+ closed atomic.Int32
1639+ stall <-chan struct{}
1640+ onSend func(ports.Frame)
1641+ onClose func()
1139 }1642 }
1140 1643
1141 func (t *recordingTransport) Send(f ports.Frame) error {1644 func (t *recordingTransport) Send(f ports.Frame) error {
1142 t.mu.Lock()1645 t.mu.Lock()
1143 t.sends = append(t.sends, f)1646 t.sends = append(t.sends, f)
1647+ onSend := t.onSend
1144 t.mu.Unlock()1648 t.mu.Unlock()
1649+ if onSend != nil {
1650+ onSend(f)
1651+ }
1145 return nil1652 return nil
1146 }1653 }
1147 1654
@@ -1153,15 +1660,24 @@ func (t *recordingTransport) Sends() []ports.Frame {
1153 1660
1154 func (t *recordingTransport) Recv() (ports.Frame, error) {1661 func (t *recordingTransport) Recv() (ports.Frame, error) {
1155 if len(t.recvs) == 0 {1662 if len(t.recvs) == 0 {
1663+ if t.stall != nil {
1664+ <-t.stall
1665+ }
1156 return ports.Frame{}, io.EOF1666 return ports.Frame{}, io.EOF
1157 }1667 }
1158 it := t.recvs[0]1668 it := t.recvs[0]
1159 t.recvs = t.recvs[1:]1669 t.recvs = t.recvs[1:]
1670+ if it.wait != nil {
1671+ <-it.wait
1672+ }
1160 return it.f, it.err1673 return it.f, it.err
1161 }1674 }
1162 1675
1163 func (t *recordingTransport) Close() error {1676 func (t *recordingTransport) Close() error {
1164 t.closed.Add(1)1677 t.closed.Add(1)
1678+ if t.onClose != nil {
1679+ t.onClose()
1680+ }
1165 return nil1681 return nil
1166 }1682 }
1167 1683
@@ -1171,17 +1687,31 @@ type runTerminal struct {
1171 rawCount atomic.Int321687 rawCount atomic.Int32
1172 restoreCount atomic.Int321688 restoreCount atomic.Int32
1173 resizeCh chan domain.Size1689 resizeCh chan domain.Size
1690+ sizeMu sync.Mutex
1691+ size domain.Size
1174 }1692 }
1175 1693
1176 func newRunTerminal() *runTerminal {1694 func newRunTerminal() *runTerminal {
1177- return &runTerminal{in: newBlockingReader(), resizeCh: make(chan domain.Size)}1695+ return &runTerminal{
1696+ in: newBlockingReader(), resizeCh: make(chan domain.Size),
1697+ size: domain.Size{Cols: 80, Rows: 24},
1698+ }
1178 }1699 }
1179 1700
1180 func (t *runTerminal) EnterRaw() (func() error, error) {1701 func (t *runTerminal) EnterRaw() (func() error, error) {
1181 t.rawCount.Add(1)1702 t.rawCount.Add(1)
1182 return func() error { t.restoreCount.Add(1); return nil }, nil1703 return func() error { t.restoreCount.Add(1); return nil }, nil
1183 }1704 }
1184-func (t *runTerminal) Size() (domain.Size, error) { return domain.Size{Cols: 80, Rows: 24}, nil }1705+func (t *runTerminal) Size() (domain.Size, error) {
1706+ t.sizeMu.Lock()
1707+ defer t.sizeMu.Unlock()
1708+ return t.size, nil
1709+}
1710+func (t *runTerminal) setSize(size domain.Size) {
1711+ t.sizeMu.Lock()
1712+ t.size = size
1713+ t.sizeMu.Unlock()
1714+}
1185 func (t *runTerminal) ResizeEvents() <-chan domain.Size { return t.resizeCh }1715 func (t *runTerminal) ResizeEvents() <-chan domain.Size { return t.resizeCh }
1186 func (t *runTerminal) In() io.Reader { return t.in }1716 func (t *runTerminal) In() io.Reader { return t.in }
1187 func (t *runTerminal) Out() io.Writer { return &t.out }1717 func (t *runTerminal) Out() io.Writer { return &t.out }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close