feat(client): cut over global recent navigation
- commit
- 2f86aba232a9ea7b05e8e36df148a8fbe95c021c
- author
- brice · · 2026-08-09 13:58 UTC
- parent
- 121c2e1
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
35 files · +713 -764
M docs/remote-resilience.md +13 -9
| @@ -27,15 +27,19 @@ directory; direct CLI remote attaches retain their client-request semantics. | |||
| 27 | 27 | ||
| 28 | 28 | Remote attachments remain direct: the selected remote daemon owns the session, | |
| 29 | 29 | PTYs, rendering, input, resize, effects, and teardown. The client does not | |
| 30 | - | proxy remote content through the local daemon. A picker-selected remote target | |
| 31 | - | is a temporary direct handoff; the client keeps one input pump and remembers a | |
| 32 | - | bounded home route. A remote daemon may request the home picker, and cancelling | |
| 33 | - | that temporary local picker returns to the parked remote route, first attempting | |
| 34 | - | resume and then falling back to a fresh attach when the resume token is stale. | |
| 35 | - | These navigation actions and the strict handshake layout use protocol v27. | |
| 36 | - | Attach handshakes may carry an exact lifecycle/name target, and successful | |
| 37 | - | Welcomes can return the daemon's committed identity; the client keeps bounded | |
| 38 | - | route identity and display snapshots separate from transport capabilities. | |
| 30 | + | proxy remote content through the local daemon. A single client process keeps a | |
| 31 | + | bounded, in-memory route history across local, direct remote, and picker- | |
| 32 | + | discovered attachments. The daemon renders the latest attachment snapshot; | |
| 33 | + | active routes are metadata-only, while `JRS` and `BSK` send typed key/generation | |
| 34 | + | actions back to the client. History is process-local and is not persisted or | |
| 35 | + | shared between clients. | |
| 36 | + | ||
| 37 | + | A picker-selected remote target is a direct handoff with one input pump. Exact | |
| 38 | + | lifecycle/name identities are carried separately from display labels, and a | |
| 39 | + | successful daemon-local switch publishes a new committed identity before the | |
| 40 | + | client republishes its snapshot. These navigation actions and the strict | |
| 41 | + | handshake layout use protocol v27. Attach handshakes may carry an exact target, | |
| 42 | + | and successful Welcomes can return the daemon's committed identity. | |
| 39 | 43 | ||
| 40 | 44 | ## Durable record compatibility | |
| 41 | 45 | ||
| @@ -27,15 +27,19 @@ directory; direct CLI remote attaches retain their client-request semantics. | |||||
| 27 | 27 | ||||
| 28 | Remote attachments remain direct: the selected remote daemon owns the session, | 28 | Remote attachments remain direct: the selected remote daemon owns the session, | ||
| 29 | PTYs, rendering, input, resize, effects, and teardown. The client does not | 29 | PTYs, rendering, input, resize, effects, and teardown. The client does not | ||
| 30 | - | proxy remote content through the local daemon. A picker-selected remote target | 30 | + | proxy remote content through the local daemon. A single client process keeps a |
| 31 | - | is a temporary direct handoff; the client keeps one input pump and remembers a | 31 | + | bounded, in-memory route history across local, direct remote, and picker- |
| 32 | - | bounded home route. A remote daemon may request the home picker, and cancelling | 32 | + | discovered attachments. The daemon renders the latest attachment snapshot; |
| 33 | - | that temporary local picker returns to the parked remote route, first attempting | 33 | + | active routes are metadata-only, while `JRS` and `BSK` send typed key/generation |
| 34 | - | resume and then falling back to a fresh attach when the resume token is stale. | 34 | + | actions back to the client. History is process-local and is not persisted or |
| 35 | - | These navigation actions and the strict handshake layout use protocol v27. | 35 | + | shared between clients. |
| 36 | - | Attach handshakes may carry an exact lifecycle/name target, and successful | 36 | + | |
| 37 | - | Welcomes can return the daemon's committed identity; the client keeps bounded | 37 | + | A picker-selected remote target is a direct handoff with one input pump. Exact |
| 38 | - | route identity and display snapshots separate from transport capabilities. | 38 | + | lifecycle/name identities are carried separately from display labels, and a |
| 39 | + | successful daemon-local switch publishes a new committed identity before the | |||
| 40 | + | client republishes its snapshot. These navigation actions and the strict | |||
| 41 | + | handshake layout use protocol v27. Attach handshakes may carry an exact target, | |||
| 42 | + | and successful Welcomes can return the daemon's committed identity. | |||
| 39 | 43 | ||||
| 40 | ## Durable record compatibility | 44 | ## Durable record compatibility | ||
| 41 | 45 | ||||
M internal/usecase/client/client.go +119 -7
| @@ -205,6 +205,7 @@ type Runner struct { | |||
| 205 | 205 | remote bool | |
| 206 | 206 | origin ports.RouteOrigin | |
| 207 | 207 | ledger *routeLedger | |
| 208 | + | routeFailure *ports.RouteNavigationFailure | |
| 208 | 209 | ||
| 209 | 210 | inputMu sync.Mutex | |
| 210 | 211 | input *terminalInputPump | |
| @@ -341,6 +342,10 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 341 | 342 | returnResumeFallback := false | |
| 342 | 343 | homeNavigationPending := false | |
| 343 | 344 | returnNavigationPending := false | |
| 345 | + | routeNavigationPending := false | |
| 346 | + | routeNavigationResumeFallback := false | |
| 347 | + | var routeNavigationFallback *attachRoute | |
| 348 | + | var routeNavigationAction *ports.RouteNavigationAction | |
| 344 | 349 | backoff := defaultReconnectBackoff.initial | |
| 345 | 350 | themeState := &terminalThemeState{} | |
| 346 | 351 | var rememberOnce sync.Once | |
| @@ -519,6 +524,36 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 519 | 524 | if !result.transportClosed { | |
| 520 | 525 | _ = connection.Close() | |
| 521 | 526 | } | |
| 527 | + | if result.routeAction != nil { | |
| 528 | + | if r.ledger == nil { | |
| 529 | + | return errors.New("vev: route ledger unavailable") | |
| 530 | + | } | |
| 531 | + | record, prior, valid, noOp := r.ledger.navigationRecords(*result.routeAction) | |
| 532 | + | if !valid { | |
| 533 | + | return errors.New("vev: stale route navigation action") | |
| 534 | + | } | |
| 535 | + | if noOp { | |
| 536 | + | continue | |
| 537 | + | } | |
| 538 | + | action := *result.routeAction | |
| 539 | + | routeNavigationAction = &action | |
| 540 | + | routeNavigationFallback = &attachRoute{dialer: prior.dialer, request: prior.request, resumeToken: prior.resumeToken} | |
| 541 | + | dialer = record.dialer | |
| 542 | + | attemptRequest = cloneAttachRequest(record.request) | |
| 543 | + | if record.resumeToken != 0 { | |
| 544 | + | attemptRequest.Intent = ports.IntentResume | |
| 545 | + | } else { | |
| 546 | + | attemptRequest.Intent = ports.IntentAttach | |
| 547 | + | } | |
| 548 | + | attemptRequest.StartupOverlay = ports.StartupOverlayNone | |
| 549 | + | attemptRequest.NavigationCapabilities = 0 | |
| 550 | + | resumeToken = record.resumeToken | |
| 551 | + | routeNavigationPending = true | |
| 552 | + | routeNavigationResumeFallback = record.resumeToken != 0 | |
| 553 | + | remote = syncReconnectRemote(reconnect, attemptRequest.Remote || r.remote) | |
| 554 | + | backoff = defaultReconnectBackoff.initial | |
| 555 | + | continue | |
| 556 | + | } | |
| 522 | 557 | if result.action != 0 { | |
| 523 | 558 | switch result.action { | |
| 524 | 559 | case ports.NavigationOpenHomePicker: | |
| @@ -594,10 +629,23 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 594 | 629 | if result.sessionName != "" { | |
| 595 | 630 | attemptRequest.SessionName = result.sessionName | |
| 596 | 631 | } | |
| 632 | + | if result.committedIdentity != nil { | |
| 633 | + | identity := *result.committedIdentity | |
| 634 | + | if attemptRequest.ExactTarget != nil && *attemptRequest.ExactTarget != identity.Target { | |
| 635 | + | attemptRequest.RemoteTarget = nil | |
| 636 | + | } | |
| 637 | + | attemptRequest.ExactTarget = &identity.Target | |
| 638 | + | } | |
| 597 | 639 | if result.welcomed { | |
| 598 | 640 | homeNavigationPending = false | |
| 599 | 641 | } | |
| 600 | 642 | if result.err == nil { | |
| 643 | + | if result.welcomed && routeNavigationPending { | |
| 644 | + | routeNavigationPending = false | |
| 645 | + | routeNavigationResumeFallback = false | |
| 646 | + | routeNavigationFallback = nil | |
| 647 | + | routeNavigationAction = nil | |
| 648 | + | } | |
| 601 | 649 | if result.welcomed && returnNavigationPending { | |
| 602 | 650 | returnNavigationPending = false | |
| 603 | 651 | returnResumeFallback = false | |
| @@ -615,6 +663,29 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 615 | 663 | restoreReturnRoute(route) | |
| 616 | 664 | continue | |
| 617 | 665 | } | |
| 666 | + | if routeNavigationPending && routeNavigationResumeFallback { | |
| 667 | + | var protocolErr *ProtocolError | |
| 668 | + | if errors.As(result.err, &protocolErr) && (protocolErr.Code == ports.ErrNoSuchSession || protocolErr.Code == ports.ErrNoSuchTarget) { | |
| 669 | + | attemptRequest.Intent = ports.IntentAttach | |
| 670 | + | resumeToken = 0 | |
| 671 | + | routeNavigationResumeFallback = false | |
| 672 | + | continue | |
| 673 | + | } | |
| 674 | + | } | |
| 675 | + | if routeNavigationPending && routeNavigationFallback != nil { | |
| 676 | + | if routeNavigationAction != nil { | |
| 677 | + | action := *routeNavigationAction | |
| 678 | + | r.routeFailure = &ports.RouteNavigationFailure{Key: action.Key, Generation: action.Generation, Code: routeFailureCode(result.err)} | |
| 679 | + | routeNavigationAction = nil | |
| 680 | + | } | |
| 681 | + | route := *routeNavigationFallback | |
| 682 | + | routeNavigationFallback = nil | |
| 683 | + | routeNavigationPending = false | |
| 684 | + | routeNavigationResumeFallback = false | |
| 685 | + | returnNavigationPending = true | |
| 686 | + | restoreReturnRoute(route) | |
| 687 | + | continue | |
| 688 | + | } | |
| 618 | 689 | if returnResumeFallback { | |
| 619 | 690 | var protocolErr *ProtocolError | |
| 620 | 691 | if errors.As(result.err, &protocolErr) && (protocolErr.Code == ports.ErrNoSuchSession || protocolErr.Code == ports.ErrNoSuchTarget) { | |
| @@ -808,6 +879,7 @@ type attachResult struct { | |||
| 808 | 879 | transportClosed bool | |
| 809 | 880 | target *ports.AttachTarget | |
| 810 | 881 | action ports.NavigationAction | |
| 882 | + | routeAction *ports.RouteNavigationAction | |
| 811 | 883 | err error | |
| 812 | 884 | } | |
| 813 | 885 | ||
| @@ -1016,6 +1088,18 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 1016 | 1088 | return welcomedResult(fmt.Errorf("vev: publishing route snapshot: %w", err)) | |
| 1017 | 1089 | } | |
| 1018 | 1090 | } | |
| 1091 | + | if a.runner.routeFailure != nil { | |
| 1092 | + | payload, err := ports.MarshalRouteNavigationFailure(*a.runner.routeFailure) | |
| 1093 | + | if err != nil { | |
| 1094 | + | return welcomedResult(fmt.Errorf("vev: encoding route navigation failure: %w", err)) | |
| 1095 | + | } | |
| 1096 | + | if err := sendHandshake(func() error { | |
| 1097 | + | return transport.Send(ports.Frame{Type: ports.MsgRouteNavigationFailure, Payload: payload}) | |
| 1098 | + | }); err != nil { | |
| 1099 | + | return welcomedResult(fmt.Errorf("vev: publishing route navigation failure: %w", err)) | |
| 1100 | + | } | |
| 1101 | + | a.runner.routeFailure = nil | |
| 1102 | + | } | |
| 1019 | 1103 | ||
| 1020 | 1104 | // 3. Enter raw mode after Welcome; Run owns restoration. | |
| 1021 | 1105 | if err := enterRaw(); err != nil { | |
| @@ -1227,6 +1311,21 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 1227 | 1311 | return context.Canceled | |
| 1228 | 1312 | } | |
| 1229 | 1313 | } | |
| 1314 | + | publishRouteSnapshot := func() error { | |
| 1315 | + | if a.runner.ledger == nil { | |
| 1316 | + | return errors.New("vev: route ledger unavailable") | |
| 1317 | + | } | |
| 1318 | + | payload, err := ports.MarshalRecentRouteSnapshot(a.runner.ledger.snapshot()) | |
| 1319 | + | if err != nil { | |
| 1320 | + | return err | |
| 1321 | + | } | |
| 1322 | + | select { | |
| 1323 | + | case sendCh <- ports.Frame{Type: ports.MsgRecentRouteSnapshot, Payload: payload}: | |
| 1324 | + | return nil | |
| 1325 | + | case <-loopCtx.Done(): | |
| 1326 | + | return context.Canceled | |
| 1327 | + | } | |
| 1328 | + | } | |
| 1230 | 1329 | sendRouteFailure := func(action ports.RouteNavigationAction, code ports.RouteFailureCode) error { | |
| 1231 | 1330 | payload, err := ports.MarshalRouteNavigationFailure(ports.RouteNavigationFailure{ | |
| 1232 | 1331 | Key: action.Key, | |
| @@ -1427,16 +1526,29 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 1427 | 1526 | if noOp { | |
| 1428 | 1527 | continue | |
| 1429 | 1528 | } | |
| 1430 | - | // Stack 2 validates and rejects stale actions without changing | |
| 1431 | - | // command authority. The live handoff connector is installed by the | |
| 1432 | - | // atomic Stack 3 cutover. | |
| 1433 | - | if ferr := sendRouteFailure(action, ports.RouteFailureUnavailable); ferr != nil { | |
| 1434 | - | return welcomedResult(ferr) | |
| 1435 | - | } | |
| 1529 | + | navigation := welcomedResult(nil) | |
| 1530 | + | navigation.routeAction = &action | |
| 1531 | + | return navigation | |
| 1436 | 1532 | case ports.MsgCommittedRouteIdentity: | |
| 1437 | - | if _, derr := ports.UnmarshalCommittedRouteIdentity(r.frame.Payload); derr != nil { | |
| 1533 | + | identity, derr := ports.UnmarshalCommittedRouteIdentity(r.frame.Payload) | |
| 1534 | + | if derr != nil { | |
| 1438 | 1535 | return welcomedResult(fmt.Errorf("vev: decoding committed route identity: %w", derr)) | |
| 1439 | 1536 | } | |
| 1537 | + | name = identity.Target.SessionName | |
| 1538 | + | committedIdentity = cloneCommittedIdentity(&identity) | |
| 1539 | + | if a.runner.ledger == nil { | |
| 1540 | + | return welcomedResult(errors.New("vev: route ledger unavailable")) | |
| 1541 | + | } | |
| 1542 | + | active, activeOK := a.runner.ledger.lookup(a.runner.ledger.activeRef()) | |
| 1543 | + | if !activeOK { | |
| 1544 | + | return welcomedResult(errors.New("vev: committed route identity has no active route")) | |
| 1545 | + | } | |
| 1546 | + | if _, derr := a.runner.ledger.commit(routeCandidateForCommittedIdentity(active, identity)); derr != nil { | |
| 1547 | + | return welcomedResult(fmt.Errorf("vev: committing daemon-local route identity: %w", derr)) | |
| 1548 | + | } | |
| 1549 | + | if derr := publishRouteSnapshot(); derr != nil { | |
| 1550 | + | return welcomedResult(fmt.Errorf("vev: publishing daemon-local route snapshot: %w", derr)) | |
| 1551 | + | } | |
| 1440 | 1552 | case ports.MsgRouteNavigationFailure: | |
| 1441 | 1553 | if _, derr := ports.UnmarshalRouteNavigationFailure(r.frame.Payload); derr != nil { | |
| 1442 | 1554 | return welcomedResult(fmt.Errorf("vev: decoding route navigation failure: %w", derr)) | |
| @@ -205,6 +205,7 @@ type Runner struct { | |||||
| 205 | remote bool | 205 | remote bool | ||
| 206 | origin ports.RouteOrigin | 206 | origin ports.RouteOrigin | ||
| 207 | ledger *routeLedger | 207 | ledger *routeLedger | ||
| 208 | + | routeFailure *ports.RouteNavigationFailure | |||
| 208 | 209 | ||||
| 209 | inputMu sync.Mutex | 210 | inputMu sync.Mutex | ||
| 210 | input *terminalInputPump | 211 | input *terminalInputPump | ||
| @@ -341,6 +342,10 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||||
| 341 | returnResumeFallback := false | 342 | returnResumeFallback := false | ||
| 342 | homeNavigationPending := false | 343 | homeNavigationPending := false | ||
| 343 | returnNavigationPending := false | 344 | returnNavigationPending := false | ||
| 345 | + | routeNavigationPending := false | |||
| 346 | + | routeNavigationResumeFallback := false | |||
| 347 | + | var routeNavigationFallback *attachRoute | |||
| 348 | + | var routeNavigationAction *ports.RouteNavigationAction | |||
| 344 | backoff := defaultReconnectBackoff.initial | 349 | backoff := defaultReconnectBackoff.initial | ||
| 345 | themeState := &terminalThemeState{} | 350 | themeState := &terminalThemeState{} | ||
| 346 | var rememberOnce sync.Once | 351 | var rememberOnce sync.Once | ||
| @@ -519,6 +524,36 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||||
| 519 | if !result.transportClosed { | 524 | if !result.transportClosed { | ||
| 520 | _ = connection.Close() | 525 | _ = connection.Close() | ||
| 521 | } | 526 | } | ||
| 527 | + | if result.routeAction != nil { | |||
| 528 | + | if r.ledger == nil { | |||
| 529 | + | return errors.New("vev: route ledger unavailable") | |||
| 530 | + | } | |||
| 531 | + | record, prior, valid, noOp := r.ledger.navigationRecords(*result.routeAction) | |||
| 532 | + | if !valid { | |||
| 533 | + | return errors.New("vev: stale route navigation action") | |||
| 534 | + | } | |||
| 535 | + | if noOp { | |||
| 536 | + | continue | |||
| 537 | + | } | |||
| 538 | + | action := *result.routeAction | |||
| 539 | + | routeNavigationAction = &action | |||
| 540 | + | routeNavigationFallback = &attachRoute{dialer: prior.dialer, request: prior.request, resumeToken: prior.resumeToken} | |||
| 541 | + | dialer = record.dialer | |||
| 542 | + | attemptRequest = cloneAttachRequest(record.request) | |||
| 543 | + | if record.resumeToken != 0 { | |||
| 544 | + | attemptRequest.Intent = ports.IntentResume | |||
| 545 | + | } else { | |||
| 546 | + | attemptRequest.Intent = ports.IntentAttach | |||
| 547 | + | } | |||
| 548 | + | attemptRequest.StartupOverlay = ports.StartupOverlayNone | |||
| 549 | + | attemptRequest.NavigationCapabilities = 0 | |||
| 550 | + | resumeToken = record.resumeToken | |||
| 551 | + | routeNavigationPending = true | |||
| 552 | + | routeNavigationResumeFallback = record.resumeToken != 0 | |||
| 553 | + | remote = syncReconnectRemote(reconnect, attemptRequest.Remote || r.remote) | |||
| 554 | + | backoff = defaultReconnectBackoff.initial | |||
| 555 | + | continue | |||
| 556 | + | } | |||
| 522 | if result.action != 0 { | 557 | if result.action != 0 { | ||
| 523 | switch result.action { | 558 | switch result.action { | ||
| 524 | case ports.NavigationOpenHomePicker: | 559 | case ports.NavigationOpenHomePicker: | ||
| @@ -594,10 +629,23 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||||
| 594 | if result.sessionName != "" { | 629 | if result.sessionName != "" { | ||
| 595 | attemptRequest.SessionName = result.sessionName | 630 | attemptRequest.SessionName = result.sessionName | ||
| 596 | } | 631 | } | ||
| 632 | + | if result.committedIdentity != nil { | |||
| 633 | + | identity := *result.committedIdentity | |||
| 634 | + | if attemptRequest.ExactTarget != nil && *attemptRequest.ExactTarget != identity.Target { | |||
| 635 | + | attemptRequest.RemoteTarget = nil | |||
| 636 | + | } | |||
| 637 | + | attemptRequest.ExactTarget = &identity.Target | |||
| 638 | + | } | |||
| 597 | if result.welcomed { | 639 | if result.welcomed { | ||
| 598 | homeNavigationPending = false | 640 | homeNavigationPending = false | ||
| 599 | } | 641 | } | ||
| 600 | if result.err == nil { | 642 | if result.err == nil { | ||
| 643 | + | if result.welcomed && routeNavigationPending { | |||
| 644 | + | routeNavigationPending = false | |||
| 645 | + | routeNavigationResumeFallback = false | |||
| 646 | + | routeNavigationFallback = nil | |||
| 647 | + | routeNavigationAction = nil | |||
| 648 | + | } | |||
| 601 | if result.welcomed && returnNavigationPending { | 649 | if result.welcomed && returnNavigationPending { | ||
| 602 | returnNavigationPending = false | 650 | returnNavigationPending = false | ||
| 603 | returnResumeFallback = false | 651 | returnResumeFallback = false | ||
| @@ -615,6 +663,29 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||||
| 615 | restoreReturnRoute(route) | 663 | restoreReturnRoute(route) | ||
| 616 | continue | 664 | continue | ||
| 617 | } | 665 | } | ||
| 666 | + | if routeNavigationPending && routeNavigationResumeFallback { | |||
| 667 | + | var protocolErr *ProtocolError | |||
| 668 | + | if errors.As(result.err, &protocolErr) && (protocolErr.Code == ports.ErrNoSuchSession || protocolErr.Code == ports.ErrNoSuchTarget) { | |||
| 669 | + | attemptRequest.Intent = ports.IntentAttach | |||
| 670 | + | resumeToken = 0 | |||
| 671 | + | routeNavigationResumeFallback = false | |||
| 672 | + | continue | |||
| 673 | + | } | |||
| 674 | + | } | |||
| 675 | + | if routeNavigationPending && routeNavigationFallback != nil { | |||
| 676 | + | if routeNavigationAction != nil { | |||
| 677 | + | action := *routeNavigationAction | |||
| 678 | + | r.routeFailure = &ports.RouteNavigationFailure{Key: action.Key, Generation: action.Generation, Code: routeFailureCode(result.err)} | |||
| 679 | + | routeNavigationAction = nil | |||
| 680 | + | } | |||
| 681 | + | route := *routeNavigationFallback | |||
| 682 | + | routeNavigationFallback = nil | |||
| 683 | + | routeNavigationPending = false | |||
| 684 | + | routeNavigationResumeFallback = false | |||
| 685 | + | returnNavigationPending = true | |||
| 686 | + | restoreReturnRoute(route) | |||
| 687 | + | continue | |||
| 688 | + | } | |||
| 618 | if returnResumeFallback { | 689 | if returnResumeFallback { | ||
| 619 | var protocolErr *ProtocolError | 690 | var protocolErr *ProtocolError | ||
| 620 | if errors.As(result.err, &protocolErr) && (protocolErr.Code == ports.ErrNoSuchSession || protocolErr.Code == ports.ErrNoSuchTarget) { | 691 | if errors.As(result.err, &protocolErr) && (protocolErr.Code == ports.ErrNoSuchSession || protocolErr.Code == ports.ErrNoSuchTarget) { | ||
| @@ -808,6 +879,7 @@ type attachResult struct { | |||||
| 808 | transportClosed bool | 879 | transportClosed bool | ||
| 809 | target *ports.AttachTarget | 880 | target *ports.AttachTarget | ||
| 810 | action ports.NavigationAction | 881 | action ports.NavigationAction | ||
| 882 | + | routeAction *ports.RouteNavigationAction | |||
| 811 | err error | 883 | err error | ||
| 812 | } | 884 | } | ||
| 813 | 885 | ||||
| @@ -1016,6 +1088,18 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||||
| 1016 | return welcomedResult(fmt.Errorf("vev: publishing route snapshot: %w", err)) | 1088 | return welcomedResult(fmt.Errorf("vev: publishing route snapshot: %w", err)) | ||
| 1017 | } | 1089 | } | ||
| 1018 | } | 1090 | } | ||
| 1091 | + | if a.runner.routeFailure != nil { | |||
| 1092 | + | payload, err := ports.MarshalRouteNavigationFailure(*a.runner.routeFailure) | |||
| 1093 | + | if err != nil { | |||
| 1094 | + | return welcomedResult(fmt.Errorf("vev: encoding route navigation failure: %w", err)) | |||
| 1095 | + | } | |||
| 1096 | + | if err := sendHandshake(func() error { | |||
| 1097 | + | return transport.Send(ports.Frame{Type: ports.MsgRouteNavigationFailure, Payload: payload}) | |||
| 1098 | + | }); err != nil { | |||
| 1099 | + | return welcomedResult(fmt.Errorf("vev: publishing route navigation failure: %w", err)) | |||
| 1100 | + | } | |||
| 1101 | + | a.runner.routeFailure = nil | |||
| 1102 | + | } | |||
| 1019 | 1103 | ||||
| 1020 | // 3. Enter raw mode after Welcome; Run owns restoration. | 1104 | // 3. Enter raw mode after Welcome; Run owns restoration. | ||
| 1021 | if err := enterRaw(); err != nil { | 1105 | if err := enterRaw(); err != nil { | ||
| @@ -1227,6 +1311,21 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||||
| 1227 | return context.Canceled | 1311 | return context.Canceled | ||
| 1228 | } | 1312 | } | ||
| 1229 | } | 1313 | } | ||
| 1314 | + | publishRouteSnapshot := func() error { | |||
| 1315 | + | if a.runner.ledger == nil { | |||
| 1316 | + | return errors.New("vev: route ledger unavailable") | |||
| 1317 | + | } | |||
| 1318 | + | payload, err := ports.MarshalRecentRouteSnapshot(a.runner.ledger.snapshot()) | |||
| 1319 | + | if err != nil { | |||
| 1320 | + | return err | |||
| 1321 | + | } | |||
| 1322 | + | select { | |||
| 1323 | + | case sendCh <- ports.Frame{Type: ports.MsgRecentRouteSnapshot, Payload: payload}: | |||
| 1324 | + | return nil | |||
| 1325 | + | case <-loopCtx.Done(): | |||
| 1326 | + | return context.Canceled | |||
| 1327 | + | } | |||
| 1328 | + | } | |||
| 1230 | sendRouteFailure := func(action ports.RouteNavigationAction, code ports.RouteFailureCode) error { | 1329 | sendRouteFailure := func(action ports.RouteNavigationAction, code ports.RouteFailureCode) error { | ||
| 1231 | payload, err := ports.MarshalRouteNavigationFailure(ports.RouteNavigationFailure{ | 1330 | payload, err := ports.MarshalRouteNavigationFailure(ports.RouteNavigationFailure{ | ||
| 1232 | Key: action.Key, | 1331 | Key: action.Key, | ||
| @@ -1427,16 +1526,29 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||||
| 1427 | if noOp { | 1526 | if noOp { | ||
| 1428 | continue | 1527 | continue | ||
| 1429 | } | 1528 | } | ||
| 1430 | - | // Stack 2 validates and rejects stale actions without changing | 1529 | + | navigation := welcomedResult(nil) |
| 1431 | - | // command authority. The live handoff connector is installed by the | 1530 | + | navigation.routeAction = &action |
| 1432 | - | // atomic Stack 3 cutover. | 1531 | + | return navigation |
| 1433 | - | if ferr := sendRouteFailure(action, ports.RouteFailureUnavailable); ferr != nil { | |||
| 1434 | - | return welcomedResult(ferr) | |||
| 1435 | - | } | |||
| 1436 | case ports.MsgCommittedRouteIdentity: | 1532 | case ports.MsgCommittedRouteIdentity: | ||
| 1437 | - | if _, derr := ports.UnmarshalCommittedRouteIdentity(r.frame.Payload); derr != nil { | 1533 | + | identity, derr := ports.UnmarshalCommittedRouteIdentity(r.frame.Payload) |
| 1534 | + | if derr != nil { | |||
| 1438 | return welcomedResult(fmt.Errorf("vev: decoding committed route identity: %w", derr)) | 1535 | return welcomedResult(fmt.Errorf("vev: decoding committed route identity: %w", derr)) | ||
| 1439 | } | 1536 | } | ||
| 1537 | + | name = identity.Target.SessionName | |||
| 1538 | + | committedIdentity = cloneCommittedIdentity(&identity) | |||
| 1539 | + | if a.runner.ledger == nil { | |||
| 1540 | + | return welcomedResult(errors.New("vev: route ledger unavailable")) | |||
| 1541 | + | } | |||
| 1542 | + | active, activeOK := a.runner.ledger.lookup(a.runner.ledger.activeRef()) | |||
| 1543 | + | if !activeOK { | |||
| 1544 | + | return welcomedResult(errors.New("vev: committed route identity has no active route")) | |||
| 1545 | + | } | |||
| 1546 | + | if _, derr := a.runner.ledger.commit(routeCandidateForCommittedIdentity(active, identity)); derr != nil { | |||
| 1547 | + | return welcomedResult(fmt.Errorf("vev: committing daemon-local route identity: %w", derr)) | |||
| 1548 | + | } | |||
| 1549 | + | if derr := publishRouteSnapshot(); derr != nil { | |||
| 1550 | + | return welcomedResult(fmt.Errorf("vev: publishing daemon-local route snapshot: %w", derr)) | |||
| 1551 | + | } | |||
| 1440 | case ports.MsgRouteNavigationFailure: | 1552 | case ports.MsgRouteNavigationFailure: | ||
| 1441 | if _, derr := ports.UnmarshalRouteNavigationFailure(r.frame.Payload); derr != nil { | 1553 | if _, derr := ports.UnmarshalRouteNavigationFailure(r.frame.Payload); derr != nil { | ||
| 1442 | return welcomedResult(fmt.Errorf("vev: decoding route navigation failure: %w", derr)) | 1554 | return welcomedResult(fmt.Errorf("vev: decoding route navigation failure: %w", derr)) | ||
M internal/usecase/client/routes.go +63 -12
| @@ -115,6 +115,9 @@ func routeCandidateForAttach(request AttachRequest, identity ports.CommittedRout | |||
| 115 | 115 | if request.RemoteTarget != nil { | |
| 116 | 116 | hostLabel = request.RemoteTarget.DisplayOrigin | |
| 117 | 117 | } | |
| 118 | + | if hostLabel == "" { | |
| 119 | + | hostLabel = originKey | |
| 120 | + | } | |
| 118 | 121 | } | |
| 119 | 122 | request = cloneAttachRequest(request) | |
| 120 | 123 | request.Origin = origin | |
| @@ -139,6 +142,29 @@ func routeCandidateForAttach(request AttachRequest, identity ports.CommittedRout | |||
| 139 | 142 | } | |
| 140 | 143 | } | |
| 141 | 144 | ||
| 145 | + | func routeCandidateForCommittedIdentity(active routeRecord, identity ports.CommittedRouteIdentity) routeCandidate { | |
| 146 | + | request := cloneAttachRequest(active.request) | |
| 147 | + | request.SessionName = identity.Target.SessionName | |
| 148 | + | request.ExactTarget = &identity.Target | |
| 149 | + | request.RemoteTarget = nil | |
| 150 | + | return routeCandidate{ | |
| 151 | + | origin: active.origin, | |
| 152 | + | originKey: active.originKey, | |
| 153 | + | target: identity.Target, | |
| 154 | + | presentation: routePresentation{ | |
| 155 | + | name: identity.Target.SessionName, | |
| 156 | + | hostLabel: active.presentation.hostLabel, | |
| 157 | + | kind: active.presentation.kind, | |
| 158 | + | ephemeral: identity.Ephemeral, | |
| 159 | + | reachability: ports.RouteReachabilityReachable, | |
| 160 | + | }, | |
| 161 | + | dialer: active.dialer, | |
| 162 | + | request: request, | |
| 163 | + | resumeToken: active.resumeToken, | |
| 164 | + | home: false, | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | ||
| 142 | 168 | func normalizeRouteOrigin(origin ports.RouteOrigin, remote bool) ports.RouteOrigin { | |
| 143 | 169 | if origin != 0 { | |
| 144 | 170 | return origin | |
| @@ -149,6 +175,20 @@ func normalizeRouteOrigin(origin ports.RouteOrigin, remote bool) ports.RouteOrig | |||
| 149 | 175 | return ports.RouteOriginLocal | |
| 150 | 176 | } | |
| 151 | 177 | ||
| 178 | + | func routeFailureCode(err error) ports.RouteFailureCode { | |
| 179 | + | var protocolErr *ProtocolError | |
| 180 | + | if errors.As(err, &protocolErr) { | |
| 181 | + | switch protocolErr.Code { | |
| 182 | + | case ports.ErrNoSuchSession, ports.ErrNoSuchTarget: | |
| 183 | + | return ports.RouteFailureNoSuchRoute | |
| 184 | + | } | |
| 185 | + | } | |
| 186 | + | if errors.Is(err, errRouteTargetChanged) { | |
| 187 | + | return ports.RouteFailureTargetChanged | |
| 188 | + | } | |
| 189 | + | return ports.RouteFailureUnavailable | |
| 190 | + | } | |
| 191 | + | ||
| 152 | 192 | func normalizeRouteOriginKey(key string, origin ports.RouteOrigin) string { | |
| 153 | 193 | if key != "" { | |
| 154 | 194 | return key | |
| @@ -402,33 +442,44 @@ type routeTransitionConnector interface { | |||
| 402 | 442 | Restore(context.Context, routeRecord) error | |
| 403 | 443 | } | |
| 404 | 444 | ||
| 405 | - | // navigationRecord validates that an action belongs to the latest complete | |
| 406 | - | // snapshot. The active route is metadata-only and selecting it is a no-op. | |
| 407 | - | func (l *routeLedger) navigationRecord(action ports.RouteNavigationAction) (routeRecord, bool, bool) { | |
| 445 | + | // navigationRecords validates an action against the latest complete snapshot | |
| 446 | + | // and captures both the selected route and the live route that must be | |
| 447 | + | // restored if the transition fails. Capturing them under one read lock keeps a | |
| 448 | + | // concurrent committed-identity update from changing the restoration target | |
| 449 | + | // between validation and dialing. | |
| 450 | + | func (l *routeLedger) navigationRecords(action ports.RouteNavigationAction) (routeRecord, routeRecord, bool, bool) { | |
| 408 | 451 | if action.SnapshotGeneration == 0 || action.Key == 0 || action.Generation == 0 { | |
| 409 | - | return routeRecord{}, false, false | |
| 452 | + | return routeRecord{}, routeRecord{}, false, false | |
| 410 | 453 | } | |
| 411 | 454 | identity := identityFromWire(ports.RouteRef{Key: action.Key, Generation: action.Generation}) | |
| 412 | 455 | l.mu.RLock() | |
| 413 | 456 | defer l.mu.RUnlock() | |
| 414 | 457 | if uint64(l.generation) != action.SnapshotGeneration { | |
| 415 | - | return routeRecord{}, false, false | |
| 458 | + | return routeRecord{}, routeRecord{}, false, false | |
| 416 | 459 | } | |
| 417 | 460 | if identity == l.active { | |
| 418 | - | return routeRecord{}, true, true | |
| 461 | + | return routeRecord{}, routeRecord{}, true, true | |
| 419 | 462 | } | |
| 420 | - | index := l.indexByIdentityLocked(identity) | |
| 421 | - | if index < 0 { | |
| 422 | - | return routeRecord{}, false, false | |
| 463 | + | selectedIndex := l.indexByIdentityLocked(identity) | |
| 464 | + | activeIndex := l.indexByIdentityLocked(l.active) | |
| 465 | + | if selectedIndex < 0 || activeIndex < 0 { | |
| 466 | + | return routeRecord{}, routeRecord{}, false, false | |
| 423 | 467 | } | |
| 424 | - | return cloneRouteRecord(l.entries[index]), true, false | |
| 468 | + | return cloneRouteRecord(l.entries[selectedIndex]), cloneRouteRecord(l.entries[activeIndex]), true, false | |
| 469 | + | } | |
| 470 | + | ||
| 471 | + | // navigationRecord validates that an action belongs to the latest complete | |
| 472 | + | // snapshot. The active route is metadata-only and selecting it is a no-op. | |
| 473 | + | func (l *routeLedger) navigationRecord(action ports.RouteNavigationAction) (routeRecord, bool, bool) { | |
| 474 | + | selected, _, ok, noOp := l.navigationRecords(action) | |
| 475 | + | return selected, ok, noOp | |
| 425 | 476 | } | |
| 426 | 477 | ||
| 427 | 478 | // navigate resolves a captured key/generation, attempts resume before exact | |
| 428 | 479 | // attach, and commits only after the connector returns a matching identity. | |
| 429 | 480 | // A failed transition restores the prior live route and leaves history alone. | |
| 430 | 481 | func (l *routeLedger) navigate(ctx context.Context, action ports.RouteNavigationAction, connector routeTransitionConnector) error { | |
| 431 | - | record, ok, noOp := l.navigationRecord(action) | |
| 482 | + | record, prior, ok, noOp := l.navigationRecords(action) | |
| 432 | 483 | if !ok { | |
| 433 | 484 | return errRouteStaleSelection | |
| 434 | 485 | } | |
| @@ -450,7 +501,7 @@ func (l *routeLedger) navigate(ctx context.Context, action ports.RouteNavigation | |||
| 450 | 501 | target: record.target, | |
| 451 | 502 | } | |
| 452 | 503 | restoreOnFailure := func(err error) error { | |
| 453 | - | if restoreErr := connector.Restore(ctx, record); restoreErr != nil { | |
| 504 | + | if restoreErr := connector.Restore(ctx, prior); restoreErr != nil { | |
| 454 | 505 | return errors.Join(err, fmt.Errorf("restoring prior route: %w", restoreErr)) | |
| 455 | 506 | } | |
| 456 | 507 | return err | |
| @@ -115,6 +115,9 @@ func routeCandidateForAttach(request AttachRequest, identity ports.CommittedRout | |||||
| 115 | if request.RemoteTarget != nil { | 115 | if request.RemoteTarget != nil { | ||
| 116 | hostLabel = request.RemoteTarget.DisplayOrigin | 116 | hostLabel = request.RemoteTarget.DisplayOrigin | ||
| 117 | } | 117 | } | ||
| 118 | + | if hostLabel == "" { | |||
| 119 | + | hostLabel = originKey | |||
| 120 | + | } | |||
| 118 | } | 121 | } | ||
| 119 | request = cloneAttachRequest(request) | 122 | request = cloneAttachRequest(request) | ||
| 120 | request.Origin = origin | 123 | request.Origin = origin | ||
| @@ -139,6 +142,29 @@ func routeCandidateForAttach(request AttachRequest, identity ports.CommittedRout | |||||
| 139 | } | 142 | } | ||
| 140 | } | 143 | } | ||
| 141 | 144 | ||||
| 145 | + | func routeCandidateForCommittedIdentity(active routeRecord, identity ports.CommittedRouteIdentity) routeCandidate { | |||
| 146 | + | request := cloneAttachRequest(active.request) | |||
| 147 | + | request.SessionName = identity.Target.SessionName | |||
| 148 | + | request.ExactTarget = &identity.Target | |||
| 149 | + | request.RemoteTarget = nil | |||
| 150 | + | return routeCandidate{ | |||
| 151 | + | origin: active.origin, | |||
| 152 | + | originKey: active.originKey, | |||
| 153 | + | target: identity.Target, | |||
| 154 | + | presentation: routePresentation{ | |||
| 155 | + | name: identity.Target.SessionName, | |||
| 156 | + | hostLabel: active.presentation.hostLabel, | |||
| 157 | + | kind: active.presentation.kind, | |||
| 158 | + | ephemeral: identity.Ephemeral, | |||
| 159 | + | reachability: ports.RouteReachabilityReachable, | |||
| 160 | + | }, | |||
| 161 | + | dialer: active.dialer, | |||
| 162 | + | request: request, | |||
| 163 | + | resumeToken: active.resumeToken, | |||
| 164 | + | home: false, | |||
| 165 | + | } | |||
| 166 | + | } | |||
| 167 | + | ||||
| 142 | func normalizeRouteOrigin(origin ports.RouteOrigin, remote bool) ports.RouteOrigin { | 168 | func normalizeRouteOrigin(origin ports.RouteOrigin, remote bool) ports.RouteOrigin { | ||
| 143 | if origin != 0 { | 169 | if origin != 0 { | ||
| 144 | return origin | 170 | return origin | ||
| @@ -149,6 +175,20 @@ func normalizeRouteOrigin(origin ports.RouteOrigin, remote bool) ports.RouteOrig | |||||
| 149 | return ports.RouteOriginLocal | 175 | return ports.RouteOriginLocal | ||
| 150 | } | 176 | } | ||
| 151 | 177 | ||||
| 178 | + | func routeFailureCode(err error) ports.RouteFailureCode { | |||
| 179 | + | var protocolErr *ProtocolError | |||
| 180 | + | if errors.As(err, &protocolErr) { | |||
| 181 | + | switch protocolErr.Code { | |||
| 182 | + | case ports.ErrNoSuchSession, ports.ErrNoSuchTarget: | |||
| 183 | + | return ports.RouteFailureNoSuchRoute | |||
| 184 | + | } | |||
| 185 | + | } | |||
| 186 | + | if errors.Is(err, errRouteTargetChanged) { | |||
| 187 | + | return ports.RouteFailureTargetChanged | |||
| 188 | + | } | |||
| 189 | + | return ports.RouteFailureUnavailable | |||
| 190 | + | } | |||
| 191 | + | ||||
| 152 | func normalizeRouteOriginKey(key string, origin ports.RouteOrigin) string { | 192 | func normalizeRouteOriginKey(key string, origin ports.RouteOrigin) string { | ||
| 153 | if key != "" { | 193 | if key != "" { | ||
| 154 | return key | 194 | return key | ||
| @@ -402,33 +442,44 @@ type routeTransitionConnector interface { | |||||
| 402 | Restore(context.Context, routeRecord) error | 442 | Restore(context.Context, routeRecord) error | ||
| 403 | } | 443 | } | ||
| 404 | 444 | ||||
| 405 | - | // navigationRecord validates that an action belongs to the latest complete | 445 | + | // navigationRecords validates an action against the latest complete snapshot |
| 406 | - | // snapshot. The active route is metadata-only and selecting it is a no-op. | 446 | + | // and captures both the selected route and the live route that must be |
| 407 | - | func (l *routeLedger) navigationRecord(action ports.RouteNavigationAction) (routeRecord, bool, bool) { | 447 | + | // restored if the transition fails. Capturing them under one read lock keeps a |
| 448 | + | // concurrent committed-identity update from changing the restoration target | |||
| 449 | + | // between validation and dialing. | |||
| 450 | + | func (l *routeLedger) navigationRecords(action ports.RouteNavigationAction) (routeRecord, routeRecord, bool, bool) { | |||
| 408 | if action.SnapshotGeneration == 0 || action.Key == 0 || action.Generation == 0 { | 451 | if action.SnapshotGeneration == 0 || action.Key == 0 || action.Generation == 0 { | ||
| 409 | - | return routeRecord{}, false, false | 452 | + | return routeRecord{}, routeRecord{}, false, false |
| 410 | } | 453 | } | ||
| 411 | identity := identityFromWire(ports.RouteRef{Key: action.Key, Generation: action.Generation}) | 454 | identity := identityFromWire(ports.RouteRef{Key: action.Key, Generation: action.Generation}) | ||
| 412 | l.mu.RLock() | 455 | l.mu.RLock() | ||
| 413 | defer l.mu.RUnlock() | 456 | defer l.mu.RUnlock() | ||
| 414 | if uint64(l.generation) != action.SnapshotGeneration { | 457 | if uint64(l.generation) != action.SnapshotGeneration { | ||
| 415 | - | return routeRecord{}, false, false | 458 | + | return routeRecord{}, routeRecord{}, false, false |
| 416 | } | 459 | } | ||
| 417 | if identity == l.active { | 460 | if identity == l.active { | ||
| 418 | - | return routeRecord{}, true, true | 461 | + | return routeRecord{}, routeRecord{}, true, true |
| 419 | } | 462 | } | ||
| 420 | - | index := l.indexByIdentityLocked(identity) | 463 | + | selectedIndex := l.indexByIdentityLocked(identity) |
| 421 | - | if index < 0 { | 464 | + | activeIndex := l.indexByIdentityLocked(l.active) |
| 422 | - | return routeRecord{}, false, false | 465 | + | if selectedIndex < 0 || activeIndex < 0 { |
| 466 | + | return routeRecord{}, routeRecord{}, false, false | |||
| 423 | } | 467 | } | ||
| 424 | - | return cloneRouteRecord(l.entries[index]), true, false | 468 | + | return cloneRouteRecord(l.entries[selectedIndex]), cloneRouteRecord(l.entries[activeIndex]), true, false |
| 469 | + | } | |||
| 470 | + | ||||
| 471 | + | // navigationRecord validates that an action belongs to the latest complete | |||
| 472 | + | // snapshot. The active route is metadata-only and selecting it is a no-op. | |||
| 473 | + | func (l *routeLedger) navigationRecord(action ports.RouteNavigationAction) (routeRecord, bool, bool) { | |||
| 474 | + | selected, _, ok, noOp := l.navigationRecords(action) | |||
| 475 | + | return selected, ok, noOp | |||
| 425 | } | 476 | } | ||
| 426 | 477 | ||||
| 427 | // navigate resolves a captured key/generation, attempts resume before exact | 478 | // navigate resolves a captured key/generation, attempts resume before exact | ||
| 428 | // attach, and commits only after the connector returns a matching identity. | 479 | // attach, and commits only after the connector returns a matching identity. | ||
| 429 | // A failed transition restores the prior live route and leaves history alone. | 480 | // A failed transition restores the prior live route and leaves history alone. | ||
| 430 | func (l *routeLedger) navigate(ctx context.Context, action ports.RouteNavigationAction, connector routeTransitionConnector) error { | 481 | func (l *routeLedger) navigate(ctx context.Context, action ports.RouteNavigationAction, connector routeTransitionConnector) error { | ||
| 431 | - | record, ok, noOp := l.navigationRecord(action) | 482 | + | record, prior, ok, noOp := l.navigationRecords(action) |
| 432 | if !ok { | 483 | if !ok { | ||
| 433 | return errRouteStaleSelection | 484 | return errRouteStaleSelection | ||
| 434 | } | 485 | } | ||
| @@ -450,7 +501,7 @@ func (l *routeLedger) navigate(ctx context.Context, action ports.RouteNavigation | |||||
| 450 | target: record.target, | 501 | target: record.target, | ||
| 451 | } | 502 | } | ||
| 452 | restoreOnFailure := func(err error) error { | 503 | restoreOnFailure := func(err error) error { | ||
| 453 | - | if restoreErr := connector.Restore(ctx, record); restoreErr != nil { | 504 | + | if restoreErr := connector.Restore(ctx, prior); restoreErr != nil { |
| 454 | return errors.Join(err, fmt.Errorf("restoring prior route: %w", restoreErr)) | 505 | return errors.Join(err, fmt.Errorf("restoring prior route: %w", restoreErr)) | ||
| 455 | } | 506 | } | ||
| 456 | return err | 507 | return err | ||
M internal/usecase/client/routes_test.go +31 -7
| @@ -69,6 +69,26 @@ func TestRouteLedgerBoundsAndImmutableSnapshot(t *testing.T) { | |||
| 69 | 69 | require.Equal(t, old, ledger.previousRef(), "the old active route becomes the previous route") | |
| 70 | 70 | } | |
| 71 | 71 | ||
| 72 | + | func TestCommittedIdentityDoesNotReassignHomeRoute(t *testing.T) { | |
| 73 | + | ledger := newRouteLedger() | |
| 74 | + | home := routeTestCandidate(1, ports.RouteOriginLocal) | |
| 75 | + | home.home = true | |
| 76 | + | homeIdentity, err := ledger.commit(home) | |
| 77 | + | require.NoError(t, err) | |
| 78 | + | active, ok := ledger.lookup(ledger.activeRef()) | |
| 79 | + | require.True(t, ok) | |
| 80 | + | ||
| 81 | + | candidate := routeCandidateForCommittedIdentity(active, ports.CommittedRouteIdentity{ | |
| 82 | + | Target: routeTestTarget(2), | |
| 83 | + | Ephemeral: true, | |
| 84 | + | }) | |
| 85 | + | committed, err := ledger.commit(candidate) | |
| 86 | + | require.NoError(t, err) | |
| 87 | + | require.Equal(t, uint64(homeIdentity.key), uint64(ledger.homeRef().Key)) | |
| 88 | + | require.NotEqual(t, ledger.homeRef(), committed.wire()) | |
| 89 | + | require.False(t, candidate.home) | |
| 90 | + | } | |
| 91 | + | ||
| 72 | 92 | func TestRouteLedgerUpsertChangesGenerationWithoutChangingOpaqueKey(t *testing.T) { | |
| 73 | 93 | ledger := newRouteLedger() | |
| 74 | 94 | candidate := routeTestCandidate(1, ports.RouteOriginRemote) | |
| @@ -95,12 +115,13 @@ func TestRouteLedgerDiscoveryCyclesAreIndependent(t *testing.T) { | |||
| 95 | 115 | } | |
| 96 | 116 | ||
| 97 | 117 | type routeTestConnector struct { | |
| 98 | - | resumeErr error | |
| 99 | - | attachErr error | |
| 100 | - | resumeCalls int | |
| 101 | - | attachCalls int | |
| 102 | - | restoreCalls int | |
| 103 | - | restoreErr error | |
| 118 | + | resumeErr error | |
| 119 | + | attachErr error | |
| 120 | + | resumeCalls int | |
| 121 | + | attachCalls int | |
| 122 | + | restoreCalls int | |
| 123 | + | restoreErr error | |
| 124 | + | restoreTarget ports.ExactSessionTarget | |
| 104 | 125 | } | |
| 105 | 126 | ||
| 106 | 127 | func (c *routeTestConnector) Resume(context.Context, routeRecord) (routeTransitionCommit, error) { | |
| @@ -111,8 +132,9 @@ func (c *routeTestConnector) Resume(context.Context, routeRecord) (routeTransiti | |||
| 111 | 132 | return routeTransitionCommit{}, errors.New("unexpected resume success") | |
| 112 | 133 | } | |
| 113 | 134 | ||
| 114 | - | func (c *routeTestConnector) Restore(_ context.Context, _ routeRecord) error { | |
| 135 | + | func (c *routeTestConnector) Restore(_ context.Context, record routeRecord) error { | |
| 115 | 136 | c.restoreCalls++ | |
| 137 | + | c.restoreTarget = record.target | |
| 116 | 138 | return c.restoreErr | |
| 117 | 139 | } | |
| 118 | 140 | ||
| @@ -172,6 +194,7 @@ func TestRouteLedgerNavigateIsTransactionalOnFailureOrTargetChange(t *testing.T) | |||
| 172 | 194 | err = ledger.navigate(context.Background(), action, connector) | |
| 173 | 195 | require.Error(t, err) | |
| 174 | 196 | require.Equal(t, before, ledger.snapshot()) | |
| 197 | + | require.Equal(t, routeTestTarget(5), connector.restoreTarget) | |
| 175 | 198 | require.Equal(t, 1, connector.restoreCalls) | |
| 176 | 199 | ||
| 177 | 200 | connector = &routeTestConnector{} | |
| @@ -180,6 +203,7 @@ func TestRouteLedgerNavigateIsTransactionalOnFailureOrTargetChange(t *testing.T) | |||
| 180 | 203 | err = ledger.navigate(context.Background(), action, mismatch) | |
| 181 | 204 | require.ErrorIs(t, err, errRouteTargetChanged) | |
| 182 | 205 | require.Equal(t, before, ledger.snapshot()) | |
| 206 | + | require.Equal(t, routeTestTarget(5), connector.restoreTarget) | |
| 183 | 207 | require.Equal(t, 1, connector.restoreCalls) | |
| 184 | 208 | } | |
| 185 | 209 | ||
| @@ -69,6 +69,26 @@ func TestRouteLedgerBoundsAndImmutableSnapshot(t *testing.T) { | |||||
| 69 | require.Equal(t, old, ledger.previousRef(), "the old active route becomes the previous route") | 69 | require.Equal(t, old, ledger.previousRef(), "the old active route becomes the previous route") | ||
| 70 | } | 70 | } | ||
| 71 | 71 | ||||
| 72 | + | func TestCommittedIdentityDoesNotReassignHomeRoute(t *testing.T) { | |||
| 73 | + | ledger := newRouteLedger() | |||
| 74 | + | home := routeTestCandidate(1, ports.RouteOriginLocal) | |||
| 75 | + | home.home = true | |||
| 76 | + | homeIdentity, err := ledger.commit(home) | |||
| 77 | + | require.NoError(t, err) | |||
| 78 | + | active, ok := ledger.lookup(ledger.activeRef()) | |||
| 79 | + | require.True(t, ok) | |||
| 80 | + | ||||
| 81 | + | candidate := routeCandidateForCommittedIdentity(active, ports.CommittedRouteIdentity{ | |||
| 82 | + | Target: routeTestTarget(2), | |||
| 83 | + | Ephemeral: true, | |||
| 84 | + | }) | |||
| 85 | + | committed, err := ledger.commit(candidate) | |||
| 86 | + | require.NoError(t, err) | |||
| 87 | + | require.Equal(t, uint64(homeIdentity.key), uint64(ledger.homeRef().Key)) | |||
| 88 | + | require.NotEqual(t, ledger.homeRef(), committed.wire()) | |||
| 89 | + | require.False(t, candidate.home) | |||
| 90 | + | } | |||
| 91 | + | ||||
| 72 | func TestRouteLedgerUpsertChangesGenerationWithoutChangingOpaqueKey(t *testing.T) { | 92 | func TestRouteLedgerUpsertChangesGenerationWithoutChangingOpaqueKey(t *testing.T) { | ||
| 73 | ledger := newRouteLedger() | 93 | ledger := newRouteLedger() | ||
| 74 | candidate := routeTestCandidate(1, ports.RouteOriginRemote) | 94 | candidate := routeTestCandidate(1, ports.RouteOriginRemote) | ||
| @@ -95,12 +115,13 @@ func TestRouteLedgerDiscoveryCyclesAreIndependent(t *testing.T) { | |||||
| 95 | } | 115 | } | ||
| 96 | 116 | ||||
| 97 | type routeTestConnector struct { | 117 | type routeTestConnector struct { | ||
| 98 | - | resumeErr error | 118 | + | resumeErr error |
| 99 | - | attachErr error | 119 | + | attachErr error |
| 100 | - | resumeCalls int | 120 | + | resumeCalls int |
| 101 | - | attachCalls int | 121 | + | attachCalls int |
| 102 | - | restoreCalls int | 122 | + | restoreCalls int |
| 103 | - | restoreErr error | 123 | + | restoreErr error |
| 124 | + | restoreTarget ports.ExactSessionTarget | |||
| 104 | } | 125 | } | ||
| 105 | 126 | ||||
| 106 | func (c *routeTestConnector) Resume(context.Context, routeRecord) (routeTransitionCommit, error) { | 127 | func (c *routeTestConnector) Resume(context.Context, routeRecord) (routeTransitionCommit, error) { | ||
| @@ -111,8 +132,9 @@ func (c *routeTestConnector) Resume(context.Context, routeRecord) (routeTransiti | |||||
| 111 | return routeTransitionCommit{}, errors.New("unexpected resume success") | 132 | return routeTransitionCommit{}, errors.New("unexpected resume success") | ||
| 112 | } | 133 | } | ||
| 113 | 134 | ||||
| 114 | - | func (c *routeTestConnector) Restore(_ context.Context, _ routeRecord) error { | 135 | + | func (c *routeTestConnector) Restore(_ context.Context, record routeRecord) error { |
| 115 | c.restoreCalls++ | 136 | c.restoreCalls++ | ||
| 137 | + | c.restoreTarget = record.target | |||
| 116 | return c.restoreErr | 138 | return c.restoreErr | ||
| 117 | } | 139 | } | ||
| 118 | 140 | ||||
| @@ -172,6 +194,7 @@ func TestRouteLedgerNavigateIsTransactionalOnFailureOrTargetChange(t *testing.T) | |||||
| 172 | err = ledger.navigate(context.Background(), action, connector) | 194 | err = ledger.navigate(context.Background(), action, connector) | ||
| 173 | require.Error(t, err) | 195 | require.Error(t, err) | ||
| 174 | require.Equal(t, before, ledger.snapshot()) | 196 | require.Equal(t, before, ledger.snapshot()) | ||
| 197 | + | require.Equal(t, routeTestTarget(5), connector.restoreTarget) | |||
| 175 | require.Equal(t, 1, connector.restoreCalls) | 198 | require.Equal(t, 1, connector.restoreCalls) | ||
| 176 | 199 | ||||
| 177 | connector = &routeTestConnector{} | 200 | connector = &routeTestConnector{} | ||
| @@ -180,6 +203,7 @@ func TestRouteLedgerNavigateIsTransactionalOnFailureOrTargetChange(t *testing.T) | |||||
| 180 | err = ledger.navigate(context.Background(), action, mismatch) | 203 | err = ledger.navigate(context.Background(), action, mismatch) | ||
| 181 | require.ErrorIs(t, err, errRouteTargetChanged) | 204 | require.ErrorIs(t, err, errRouteTargetChanged) | ||
| 182 | require.Equal(t, before, ledger.snapshot()) | 205 | require.Equal(t, before, ledger.snapshot()) | ||
| 206 | + | require.Equal(t, routeTestTarget(5), connector.restoreTarget) | |||
| 183 | require.Equal(t, 1, connector.restoreCalls) | 207 | require.Equal(t, 1, connector.restoreCalls) | ||
| 184 | } | 208 | } | ||
| 185 | 209 | ||||
M internal/usecase/daemon/attachment_effect_gate_test.go +17 -3
| @@ -699,7 +699,12 @@ func TestAttachedNavigationCommandSendsResultAfterLocalTransition(t *testing.T) | |||
| 699 | 699 | d.mu.Lock() | |
| 700 | 700 | d.sessions[target.id] = target | |
| 701 | 701 | d.mu.Unlock() | |
| 702 | - | ac.previousSession.Set(target.id) | |
| 702 | + | ac.setRouteSnapshot(ports.RecentRouteSnapshot{ | |
| 703 | + | Generation: 2, | |
| 704 | + | Active: ports.RouteRef{Key: 2, Generation: 2}, | |
| 705 | + | Previous: ports.RouteRef{Key: 1, Generation: 1}, | |
| 706 | + | Entries: []ports.RecentRouteEntry{{Key: 1, Generation: 1, Name: "target", Kind: ports.RouteKindLocal}}, | |
| 707 | + | }) | |
| 703 | 708 | ||
| 704 | 709 | transport := &closeTrackingTransport{} | |
| 705 | 710 | ac.replaceTransport(transport) | |
| @@ -713,7 +718,7 @@ func TestAttachedNavigationCommandSendsResultAfterLocalTransition(t *testing.T) | |||
| 713 | 718 | require.NoError(t, err) | |
| 714 | 719 | ||
| 715 | 720 | require.False(t, d.handleAttachmentClientFrame(token, ports.Frame{Type: ports.MsgCommand, Payload: payload})) | |
| 716 | - | require.Same(t, target, ac.currentAttachmentSession()) | |
| 721 | + | require.Same(t, source, ac.currentAttachmentSession()) | |
| 717 | 722 | frames := transport.Sends() | |
| 718 | 723 | require.NotEmpty(t, frames) | |
| 719 | 724 | var result ports.CommandResult | |
| @@ -726,7 +731,16 @@ func TestAttachedNavigationCommandSendsResultAfterLocalTransition(t *testing.T) | |||
| 726 | 731 | break | |
| 727 | 732 | } | |
| 728 | 733 | require.True(t, result.OK, result.Text) | |
| 729 | - | require.False(t, transport.Closed(), "local navigation must not tear down the transitioned attachment") | |
| 734 | + | var action ports.RouteNavigationAction | |
| 735 | + | for _, frame := range frames { | |
| 736 | + | if frame.Type != ports.MsgNavigateRecentRoute { | |
| 737 | + | continue | |
| 738 | + | } | |
| 739 | + | action, err = ports.UnmarshalRouteNavigationAction(frame.Payload) | |
| 740 | + | require.NoError(t, err) | |
| 741 | + | } | |
| 742 | + | require.Equal(t, ports.RouteNavigationAction{SnapshotGeneration: 2, Key: 1, Generation: 1}, action) | |
| 743 | + | require.False(t, transport.Closed(), "client navigation must not tear down the attachment") | |
| 730 | 744 | } | |
| 731 | 745 | ||
| 732 | 746 | func TestJumpAttentionAdmittedHandoffCrossesSessions(t *testing.T) { | |
| @@ -699,7 +699,12 @@ func TestAttachedNavigationCommandSendsResultAfterLocalTransition(t *testing.T) | |||||
| 699 | d.mu.Lock() | 699 | d.mu.Lock() | ||
| 700 | d.sessions[target.id] = target | 700 | d.sessions[target.id] = target | ||
| 701 | d.mu.Unlock() | 701 | d.mu.Unlock() | ||
| 702 | - | ac.previousSession.Set(target.id) | 702 | + | ac.setRouteSnapshot(ports.RecentRouteSnapshot{ |
| 703 | + | Generation: 2, | |||
| 704 | + | Active: ports.RouteRef{Key: 2, Generation: 2}, | |||
| 705 | + | Previous: ports.RouteRef{Key: 1, Generation: 1}, | |||
| 706 | + | Entries: []ports.RecentRouteEntry{{Key: 1, Generation: 1, Name: "target", Kind: ports.RouteKindLocal}}, | |||
| 707 | + | }) | |||
| 703 | 708 | ||||
| 704 | transport := &closeTrackingTransport{} | 709 | transport := &closeTrackingTransport{} | ||
| 705 | ac.replaceTransport(transport) | 710 | ac.replaceTransport(transport) | ||
| @@ -713,7 +718,7 @@ func TestAttachedNavigationCommandSendsResultAfterLocalTransition(t *testing.T) | |||||
| 713 | require.NoError(t, err) | 718 | require.NoError(t, err) | ||
| 714 | 719 | ||||
| 715 | require.False(t, d.handleAttachmentClientFrame(token, ports.Frame{Type: ports.MsgCommand, Payload: payload})) | 720 | require.False(t, d.handleAttachmentClientFrame(token, ports.Frame{Type: ports.MsgCommand, Payload: payload})) | ||
| 716 | - | require.Same(t, target, ac.currentAttachmentSession()) | 721 | + | require.Same(t, source, ac.currentAttachmentSession()) |
| 717 | frames := transport.Sends() | 722 | frames := transport.Sends() | ||
| 718 | require.NotEmpty(t, frames) | 723 | require.NotEmpty(t, frames) | ||
| 719 | var result ports.CommandResult | 724 | var result ports.CommandResult | ||
| @@ -726,7 +731,16 @@ func TestAttachedNavigationCommandSendsResultAfterLocalTransition(t *testing.T) | |||||
| 726 | break | 731 | break | ||
| 727 | } | 732 | } | ||
| 728 | require.True(t, result.OK, result.Text) | 733 | require.True(t, result.OK, result.Text) | ||
| 729 | - | require.False(t, transport.Closed(), "local navigation must not tear down the transitioned attachment") | 734 | + | var action ports.RouteNavigationAction |
| 735 | + | for _, frame := range frames { | |||
| 736 | + | if frame.Type != ports.MsgNavigateRecentRoute { | |||
| 737 | + | continue | |||
| 738 | + | } | |||
| 739 | + | action, err = ports.UnmarshalRouteNavigationAction(frame.Payload) | |||
| 740 | + | require.NoError(t, err) | |||
| 741 | + | } | |||
| 742 | + | require.Equal(t, ports.RouteNavigationAction{SnapshotGeneration: 2, Key: 1, Generation: 1}, action) | |||
| 743 | + | require.False(t, transport.Closed(), "client navigation must not tear down the attachment") | |||
| 730 | } | 744 | } | ||
| 731 | 745 | ||||
| 732 | func TestJumpAttentionAdmittedHandoffCrossesSessions(t *testing.T) { | 746 | func TestJumpAttentionAdmittedHandoffCrossesSessions(t *testing.T) { | ||
M internal/usecase/daemon/attachment_transition.go +5 -0
| @@ -176,6 +176,11 @@ func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachme | |||
| 176 | 176 | if err != nil { | |
| 177 | 177 | return result, err | |
| 178 | 178 | } | |
| 179 | + | if result.published.ac != nil && result.published.ac.routeSnapshotCopy().Generation != 0 { | |
| 180 | + | if identityErr := d.sendCommittedRouteIdentityForAttachment(result.published); identityErr != nil { | |
| 181 | + | d.log.Warn("publishing committed route identity failed", "err", identityErr, "action", req.action) | |
| 182 | + | } | |
| 183 | + | } | |
| 179 | 184 | if result.sourceGeometrySession != nil { | |
| 180 | 185 | d.recalculateSessionGeometryAndInvalidateAsync(result.sourceGeometrySession, "attachment_transition.go") | |
| 181 | 186 | } | |
| @@ -176,6 +176,11 @@ func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachme | |||||
| 176 | if err != nil { | 176 | if err != nil { | ||
| 177 | return result, err | 177 | return result, err | ||
| 178 | } | 178 | } | ||
| 179 | + | if result.published.ac != nil && result.published.ac.routeSnapshotCopy().Generation != 0 { | |||
| 180 | + | if identityErr := d.sendCommittedRouteIdentityForAttachment(result.published); identityErr != nil { | |||
| 181 | + | d.log.Warn("publishing committed route identity failed", "err", identityErr, "action", req.action) | |||
| 182 | + | } | |||
| 183 | + | } | |||
| 179 | if result.sourceGeometrySession != nil { | 184 | if result.sourceGeometrySession != nil { | ||
| 180 | d.recalculateSessionGeometryAndInvalidateAsync(result.sourceGeometrySession, "attachment_transition.go") | 185 | d.recalculateSessionGeometryAndInvalidateAsync(result.sourceGeometrySession, "attachment_transition.go") | ||
| 181 | } | 186 | } | ||
M internal/usecase/daemon/attention_test.go +0 -43
| @@ -419,49 +419,6 @@ func TestComposeFrameAttentionFrameChangeDamagesOnlyBars(t *testing.T) { | |||
| 419 | 419 | } | |
| 420 | 420 | } | |
| 421 | 421 | ||
| 422 | - | // TestCloseRingingTabRefreshesOtherSessionBottomBar covers Fix 2c: closing a | |
| 423 | - | // ringing tab in one session must refresh the bottom bar of a client attached | |
| 424 | - | // to a DIFFERENT session, since that client's bar shows the ringing session's | |
| 425 | - | // bell until it stops. | |
| 426 | - | func TestCloseRingingTabRefreshesOtherSessionBottomBar(t *testing.T) { | |
| 427 | - | pA1, releaseA1 := newBlockingPTY(t) | |
| 428 | - | pA2, releaseA2 := newBlockingPTY(t) | |
| 429 | - | pB, releaseB := newBlockingPTY(t) | |
| 430 | - | defer releaseA1() | |
| 431 | - | defer releaseA2() | |
| 432 | - | defer releaseB() | |
| 433 | - | ||
| 434 | - | d, sessA, _, sendsA := newManualSessionWithPTYs(t, pA1, pA2) | |
| 435 | - | sessA.name = "ringer" | |
| 436 | - | ringing := sessA.tabs[1] | |
| 437 | - | sessA.mu.Lock() | |
| 438 | - | ringing.attention = true | |
| 439 | - | ringing.attentionAt = time.Unix(1, 0) | |
| 440 | - | sessA.mu.Unlock() | |
| 441 | - | d.setAttentionFrame(1) // pulse frame 0 renders the bell glyph as blank | |
| 442 | - | ||
| 443 | - | trB, sendsB := newCapturingTransport(t) | |
| 444 | - | acB := &attachedClient{tr: trB, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 445 | - | acB.initOverlays() | |
| 446 | - | sctxB, cancelB := context.WithCancel(d.serveCtx) | |
| 447 | - | t.Cleanup(cancelB) | |
| 448 | - | tbB := newTestTabWithContext(pB, sctxB, cancelB) | |
| 449 | - | sessB := &session{sessionCore: sessionCore{id: "sessB", name: "other", attachments: map[*attachedClient]struct{}{acB: {}}}, ctx: sctxB, cancel: cancelB, tabs: []*tab{tbB}} | |
| 450 | - | acB.setSession(sessB) | |
| 451 | - | acB.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acB}, nil) | |
| 452 | - | d.sessions[sessB.id] = sessB | |
| 453 | - | ||
| 454 | - | d.paint(sessB, acB, true, nil) | |
| 455 | - | before := mustOutputData(t, sendsB) | |
| 456 | - | require.Contains(t, string(before), string(ui.AttentionGlyph)) | |
| 457 | - | ||
| 458 | - | require.NoError(t, d.closeTab(sessA, ringing, true)) | |
| 459 | - | _ = mustOutputData(t, sendsA) | |
| 460 | - | ||
| 461 | - | after := mustOutputData(t, sendsB) | |
| 462 | - | require.NotContains(t, string(after), string(ui.AttentionGlyph)) | |
| 463 | - | } | |
| 464 | - | ||
| 465 | 422 | func TestAltAJumpAttentionSelectsOldestLocalTab(t *testing.T) { | |
| 466 | 423 | d, sess, ac, sends, releases := newManualTabSession(t, 3) | |
| 467 | 424 | sess.mu.Lock() | |
| @@ -419,49 +419,6 @@ func TestComposeFrameAttentionFrameChangeDamagesOnlyBars(t *testing.T) { | |||||
| 419 | } | 419 | } | ||
| 420 | } | 420 | } | ||
| 421 | 421 | ||||
| 422 | - | // TestCloseRingingTabRefreshesOtherSessionBottomBar covers Fix 2c: closing a | |||
| 423 | - | // ringing tab in one session must refresh the bottom bar of a client attached | |||
| 424 | - | // to a DIFFERENT session, since that client's bar shows the ringing session's | |||
| 425 | - | // bell until it stops. | |||
| 426 | - | func TestCloseRingingTabRefreshesOtherSessionBottomBar(t *testing.T) { | |||
| 427 | - | pA1, releaseA1 := newBlockingPTY(t) | |||
| 428 | - | pA2, releaseA2 := newBlockingPTY(t) | |||
| 429 | - | pB, releaseB := newBlockingPTY(t) | |||
| 430 | - | defer releaseA1() | |||
| 431 | - | defer releaseA2() | |||
| 432 | - | defer releaseB() | |||
| 433 | - | ||||
| 434 | - | d, sessA, _, sendsA := newManualSessionWithPTYs(t, pA1, pA2) | |||
| 435 | - | sessA.name = "ringer" | |||
| 436 | - | ringing := sessA.tabs[1] | |||
| 437 | - | sessA.mu.Lock() | |||
| 438 | - | ringing.attention = true | |||
| 439 | - | ringing.attentionAt = time.Unix(1, 0) | |||
| 440 | - | sessA.mu.Unlock() | |||
| 441 | - | d.setAttentionFrame(1) // pulse frame 0 renders the bell glyph as blank | |||
| 442 | - | ||||
| 443 | - | trB, sendsB := newCapturingTransport(t) | |||
| 444 | - | acB := &attachedClient{tr: trB, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |||
| 445 | - | acB.initOverlays() | |||
| 446 | - | sctxB, cancelB := context.WithCancel(d.serveCtx) | |||
| 447 | - | t.Cleanup(cancelB) | |||
| 448 | - | tbB := newTestTabWithContext(pB, sctxB, cancelB) | |||
| 449 | - | sessB := &session{sessionCore: sessionCore{id: "sessB", name: "other", attachments: map[*attachedClient]struct{}{acB: {}}}, ctx: sctxB, cancel: cancelB, tabs: []*tab{tbB}} | |||
| 450 | - | acB.setSession(sessB) | |||
| 451 | - | acB.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acB}, nil) | |||
| 452 | - | d.sessions[sessB.id] = sessB | |||
| 453 | - | ||||
| 454 | - | d.paint(sessB, acB, true, nil) | |||
| 455 | - | before := mustOutputData(t, sendsB) | |||
| 456 | - | require.Contains(t, string(before), string(ui.AttentionGlyph)) | |||
| 457 | - | ||||
| 458 | - | require.NoError(t, d.closeTab(sessA, ringing, true)) | |||
| 459 | - | _ = mustOutputData(t, sendsA) | |||
| 460 | - | ||||
| 461 | - | after := mustOutputData(t, sendsB) | |||
| 462 | - | require.NotContains(t, string(after), string(ui.AttentionGlyph)) | |||
| 463 | - | } | |||
| 464 | - | ||||
| 465 | func TestAltAJumpAttentionSelectsOldestLocalTab(t *testing.T) { | 422 | func TestAltAJumpAttentionSelectsOldestLocalTab(t *testing.T) { | ||
| 466 | d, sess, ac, sends, releases := newManualTabSession(t, 3) | 423 | d, sess, ac, sends, releases := newManualTabSession(t, 3) | ||
| 467 | sess.mu.Lock() | 424 | sess.mu.Lock() | ||
M internal/usecase/daemon/client.go +14 -22
| @@ -85,22 +85,19 @@ type attachedClient struct { | |||
| 85 | 85 | // view is attachment-local navigation state. It is never inferred from a | |
| 86 | 86 | // session-wide active tab, so multiple attachments can observe different | |
| 87 | 87 | // tabs and panes without changing shared session ownership. | |
| 88 | - | viewMu sync.Mutex | |
| 89 | - | view attachmentView | |
| 90 | - | sess Guarded[*session] | |
| 91 | - | mouseScan mouse.Scanner | |
| 92 | - | themeMu sync.Mutex | |
| 93 | - | clientTheme themeui.Theme | |
| 94 | - | appliedTheme appliedTheme | |
| 95 | - | lastCursor cursorOut | |
| 96 | - | renderStages renderStageHooks // optional render and handoff observability hooks | |
| 97 | - | // previousSession is guarded independently. It is retained through temporary | |
| 98 | - | // setSession(nil) hand-offs and cleared only on terminal teardown. | |
| 99 | - | previousSession Guarded[domain.SessionID] | |
| 100 | - | linkMu sync.Mutex | |
| 101 | - | sendMu sync.Mutex | |
| 102 | - | routeMu sync.RWMutex | |
| 103 | - | routeSnapshot ports.RecentRouteSnapshot | |
| 88 | + | viewMu sync.Mutex | |
| 89 | + | view attachmentView | |
| 90 | + | sess Guarded[*session] | |
| 91 | + | mouseScan mouse.Scanner | |
| 92 | + | themeMu sync.Mutex | |
| 93 | + | clientTheme themeui.Theme | |
| 94 | + | appliedTheme appliedTheme | |
| 95 | + | lastCursor cursorOut | |
| 96 | + | renderStages renderStageHooks // optional render and handoff observability hooks | |
| 97 | + | linkMu sync.Mutex | |
| 98 | + | sendMu sync.Mutex | |
| 99 | + | routeMu sync.RWMutex | |
| 100 | + | routeSnapshot ports.RecentRouteSnapshot | |
| 104 | 101 | // routeCreatedSession marks a session created by this attachment's route. | |
| 105 | 102 | // A handshake that never commits Welcome must tear down that exact empty | |
| 106 | 103 | // session, while an attachment routed to an existing session must not. | |
| @@ -227,12 +224,6 @@ func (ac *attachedClient) currentSession() *session { | |||
| 227 | 224 | ||
| 228 | 225 | func (ac *attachedClient) setSession(sess *session) { ac.sess.Set(sess) } | |
| 229 | 226 | ||
| 230 | - | func (ac *attachedClient) clearPreviousSession() { | |
| 231 | - | if ac != nil { | |
| 232 | - | ac.previousSession.Set("") | |
| 233 | - | } | |
| 234 | - | } | |
| 235 | - | ||
| 236 | 227 | // pruneCaptureFrames releases snapshots for panes that have left their owner. | |
| 237 | 228 | // Callers must not hold daemon, session, tab, or pane locks. | |
| 238 | 229 | func (ac *attachedClient) pruneCaptureFrames(panes ...*pane) { | |
| @@ -358,6 +349,7 @@ func (ac *attachedClient) currentTransportIs(tr ports.Transport) bool { | |||
| 358 | 349 | } | |
| 359 | 350 | ||
| 360 | 351 | func (ac *attachedClient) setRouteSnapshot(snapshot ports.RecentRouteSnapshot) { | |
| 352 | + | snapshot.Entries = append([]ports.RecentRouteEntry(nil), snapshot.Entries...) | |
| 361 | 353 | ac.routeMu.Lock() | |
| 362 | 354 | ac.routeSnapshot = snapshot | |
| 363 | 355 | ac.routeMu.Unlock() | |
| @@ -85,22 +85,19 @@ type attachedClient struct { | |||||
| 85 | // view is attachment-local navigation state. It is never inferred from a | 85 | // view is attachment-local navigation state. It is never inferred from a | ||
| 86 | // session-wide active tab, so multiple attachments can observe different | 86 | // session-wide active tab, so multiple attachments can observe different | ||
| 87 | // tabs and panes without changing shared session ownership. | 87 | // tabs and panes without changing shared session ownership. | ||
| 88 | - | viewMu sync.Mutex | 88 | + | viewMu sync.Mutex |
| 89 | - | view attachmentView | 89 | + | view attachmentView |
| 90 | - | sess Guarded[*session] | 90 | + | sess Guarded[*session] |
| 91 | - | mouseScan mouse.Scanner | 91 | + | mouseScan mouse.Scanner |
| 92 | - | themeMu sync.Mutex | 92 | + | themeMu sync.Mutex |
| 93 | - | clientTheme themeui.Theme | 93 | + | clientTheme themeui.Theme |
| 94 | - | appliedTheme appliedTheme | 94 | + | appliedTheme appliedTheme |
| 95 | - | lastCursor cursorOut | 95 | + | lastCursor cursorOut |
| 96 | - | renderStages renderStageHooks // optional render and handoff observability hooks | 96 | + | renderStages renderStageHooks // optional render and handoff observability hooks |
| 97 | - | // previousSession is guarded independently. It is retained through temporary | 97 | + | linkMu sync.Mutex |
| 98 | - | // setSession(nil) hand-offs and cleared only on terminal teardown. | 98 | + | sendMu sync.Mutex |
| 99 | - | previousSession Guarded[domain.SessionID] | 99 | + | routeMu sync.RWMutex |
| 100 | - | linkMu sync.Mutex | 100 | + | routeSnapshot ports.RecentRouteSnapshot |
| 101 | - | sendMu sync.Mutex | |||
| 102 | - | routeMu sync.RWMutex | |||
| 103 | - | routeSnapshot ports.RecentRouteSnapshot | |||
| 104 | // routeCreatedSession marks a session created by this attachment's route. | 101 | // routeCreatedSession marks a session created by this attachment's route. | ||
| 105 | // A handshake that never commits Welcome must tear down that exact empty | 102 | // A handshake that never commits Welcome must tear down that exact empty | ||
| 106 | // session, while an attachment routed to an existing session must not. | 103 | // session, while an attachment routed to an existing session must not. | ||
| @@ -227,12 +224,6 @@ func (ac *attachedClient) currentSession() *session { | |||||
| 227 | 224 | ||||
| 228 | func (ac *attachedClient) setSession(sess *session) { ac.sess.Set(sess) } | 225 | func (ac *attachedClient) setSession(sess *session) { ac.sess.Set(sess) } | ||
| 229 | 226 | ||||
| 230 | - | func (ac *attachedClient) clearPreviousSession() { | |||
| 231 | - | if ac != nil { | |||
| 232 | - | ac.previousSession.Set("") | |||
| 233 | - | } | |||
| 234 | - | } | |||
| 235 | - | ||||
| 236 | // pruneCaptureFrames releases snapshots for panes that have left their owner. | 227 | // pruneCaptureFrames releases snapshots for panes that have left their owner. | ||
| 237 | // Callers must not hold daemon, session, tab, or pane locks. | 228 | // Callers must not hold daemon, session, tab, or pane locks. | ||
| 238 | func (ac *attachedClient) pruneCaptureFrames(panes ...*pane) { | 229 | func (ac *attachedClient) pruneCaptureFrames(panes ...*pane) { | ||
| @@ -358,6 +349,7 @@ func (ac *attachedClient) currentTransportIs(tr ports.Transport) bool { | |||||
| 358 | } | 349 | } | ||
| 359 | 350 | ||||
| 360 | func (ac *attachedClient) setRouteSnapshot(snapshot ports.RecentRouteSnapshot) { | 351 | func (ac *attachedClient) setRouteSnapshot(snapshot ports.RecentRouteSnapshot) { | ||
| 352 | + | snapshot.Entries = append([]ports.RecentRouteEntry(nil), snapshot.Entries...) | |||
| 361 | ac.routeMu.Lock() | 353 | ac.routeMu.Lock() | ||
| 362 | ac.routeSnapshot = snapshot | 354 | ac.routeSnapshot = snapshot | ||
| 363 | ac.routeMu.Unlock() | 355 | ac.routeMu.Unlock() | ||
M internal/usecase/daemon/client_frame_routing.go +18 -1
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "errors" | |
| 5 | 5 | "runtime" | |
| 6 | 6 | ||
| 7 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | 8 | "github.com/bnema/vev/internal/ports" | |
| 8 | 9 | "github.com/bnema/vev/internal/usecase/command" | |
| 9 | 10 | ) | |
| @@ -133,9 +134,25 @@ func (d *Daemon) handleAttachmentClientFrame(token attachmentConnectionToken, f | |||
| 133 | 134 | d.log.Warn("malformed recent route snapshot", "err", derr) | |
| 134 | 135 | } | |
| 135 | 136 | case ports.MsgRouteNavigationFailure: | |
| 136 | - | if _, derr := ports.UnmarshalRouteNavigationFailure(f.Payload); derr != nil { | |
| 137 | + | failure, derr := ports.UnmarshalRouteNavigationFailure(f.Payload) | |
| 138 | + | if derr != nil { | |
| 137 | 139 | d.log.Warn("malformed route navigation failure", "err", derr) | |
| 140 | + | break | |
| 141 | + | } | |
| 142 | + | message := "route navigation failed" | |
| 143 | + | switch failure.Code { | |
| 144 | + | case ports.RouteFailureStaleSelection: | |
| 145 | + | message = "that recent route is no longer available" | |
| 146 | + | case ports.RouteFailureTargetChanged: | |
| 147 | + | message = "that recent route changed before attach" | |
| 148 | + | case ports.RouteFailureOriginUnavailable: | |
| 149 | + | message = "the original route is no longer available" | |
| 150 | + | case ports.RouteFailureUnavailable: | |
| 151 | + | message = "the selected route is unavailable" | |
| 152 | + | case ports.RouteFailureNoSuchRoute: | |
| 153 | + | message = "that recent route no longer exists" | |
| 138 | 154 | } | |
| 155 | + | d.notify(token.sess, domain.NoticeWarn, domain.NoticeSessionUnavailable, message, nil) | |
| 139 | 156 | case ports.MsgCommand: | |
| 140 | 157 | request, derr := ports.UnmarshalCommandRequest(f.Payload) | |
| 141 | 158 | if derr != nil { | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "errors" | 4 | "errors" | ||
| 5 | "runtime" | 5 | "runtime" | ||
| 6 | 6 | ||||
| 7 | + | "github.com/bnema/vev/internal/domain" | |||
| 7 | "github.com/bnema/vev/internal/ports" | 8 | "github.com/bnema/vev/internal/ports" | ||
| 8 | "github.com/bnema/vev/internal/usecase/command" | 9 | "github.com/bnema/vev/internal/usecase/command" | ||
| 9 | ) | 10 | ) | ||
| @@ -133,9 +134,25 @@ func (d *Daemon) handleAttachmentClientFrame(token attachmentConnectionToken, f | |||||
| 133 | d.log.Warn("malformed recent route snapshot", "err", derr) | 134 | d.log.Warn("malformed recent route snapshot", "err", derr) | ||
| 134 | } | 135 | } | ||
| 135 | case ports.MsgRouteNavigationFailure: | 136 | case ports.MsgRouteNavigationFailure: | ||
| 136 | - | if _, derr := ports.UnmarshalRouteNavigationFailure(f.Payload); derr != nil { | 137 | + | failure, derr := ports.UnmarshalRouteNavigationFailure(f.Payload) |
| 138 | + | if derr != nil { | |||
| 137 | d.log.Warn("malformed route navigation failure", "err", derr) | 139 | d.log.Warn("malformed route navigation failure", "err", derr) | ||
| 140 | + | break | |||
| 141 | + | } | |||
| 142 | + | message := "route navigation failed" | |||
| 143 | + | switch failure.Code { | |||
| 144 | + | case ports.RouteFailureStaleSelection: | |||
| 145 | + | message = "that recent route is no longer available" | |||
| 146 | + | case ports.RouteFailureTargetChanged: | |||
| 147 | + | message = "that recent route changed before attach" | |||
| 148 | + | case ports.RouteFailureOriginUnavailable: | |||
| 149 | + | message = "the original route is no longer available" | |||
| 150 | + | case ports.RouteFailureUnavailable: | |||
| 151 | + | message = "the selected route is unavailable" | |||
| 152 | + | case ports.RouteFailureNoSuchRoute: | |||
| 153 | + | message = "that recent route no longer exists" | |||
| 138 | } | 154 | } | ||
| 155 | + | d.notify(token.sess, domain.NoticeWarn, domain.NoticeSessionUnavailable, message, nil) | |||
| 139 | case ports.MsgCommand: | 156 | case ports.MsgCommand: | ||
| 140 | request, derr := ports.UnmarshalCommandRequest(f.Payload) | 157 | request, derr := ports.UnmarshalCommandRequest(f.Payload) | ||
| 141 | if derr != nil { | 158 | if derr != nil { | ||
M internal/usecase/daemon/client_transport_errors.go +0 -2
| @@ -96,7 +96,6 @@ func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed port | |||
| 96 | 96 | d.closePicker(ac) | |
| 97 | 97 | ||
| 98 | 98 | d.resetScreenDefaultColors(sess) | |
| 99 | - | ac.clearPreviousSession() | |
| 100 | 99 | if explicit && notice { | |
| 101 | 100 | // Synchronous so the ack is delivered before the transport closes | |
| 102 | 101 | // (the client is actively awaiting it), but deadline-bounded so a | |
| @@ -183,7 +182,6 @@ func (d *Daemon) finishSendErrorDetach(sess *session, ac *attachedClient, failed | |||
| 183 | 182 | d.clearParkingInFlight(d.resumeTokenSnapshot(ac), ac) | |
| 184 | 183 | d.closePicker(ac) | |
| 185 | 184 | d.resetScreenDefaultColors(sess) | |
| 186 | - | ac.clearPreviousSession() | |
| 187 | 185 | _ = ac.closeCapturedTransport(failed) | |
| 188 | 186 | d.log.Warn("detached client after send error", "session", name) | |
| 189 | 187 | } | |
| @@ -96,7 +96,6 @@ func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed port | |||||
| 96 | d.closePicker(ac) | 96 | d.closePicker(ac) | ||
| 97 | 97 | ||||
| 98 | d.resetScreenDefaultColors(sess) | 98 | d.resetScreenDefaultColors(sess) | ||
| 99 | - | ac.clearPreviousSession() | |||
| 100 | if explicit && notice { | 99 | if explicit && notice { | ||
| 101 | // Synchronous so the ack is delivered before the transport closes | 100 | // Synchronous so the ack is delivered before the transport closes | ||
| 102 | // (the client is actively awaiting it), but deadline-bounded so a | 101 | // (the client is actively awaiting it), but deadline-bounded so a | ||
| @@ -183,7 +182,6 @@ func (d *Daemon) finishSendErrorDetach(sess *session, ac *attachedClient, failed | |||||
| 183 | d.clearParkingInFlight(d.resumeTokenSnapshot(ac), ac) | 182 | d.clearParkingInFlight(d.resumeTokenSnapshot(ac), ac) | ||
| 184 | d.closePicker(ac) | 183 | d.closePicker(ac) | ||
| 185 | d.resetScreenDefaultColors(sess) | 184 | d.resetScreenDefaultColors(sess) | ||
| 186 | - | ac.clearPreviousSession() | |||
| 187 | _ = ac.closeCapturedTransport(failed) | 185 | _ = ac.closeCapturedTransport(failed) | ||
| 188 | d.log.Warn("detached client after send error", "session", name) | 186 | d.log.Warn("detached client after send error", "session", name) | ||
| 189 | } | 187 | } | ||
M internal/usecase/daemon/input_test.go +44 -178
| @@ -778,197 +778,69 @@ func TestPaletteNextPreviousSwitchActiveTab(t *testing.T) { | |||
| 778 | 778 | } | |
| 779 | 779 | } | |
| 780 | 780 | ||
| 781 | - | func TestPaletteBackSessionTogglesPreviousSession(t *testing.T) { | |
| 782 | - | d, current, ac, _, releases := newRecentNavigationTestSessions(t) | |
| 783 | - | defer releaseAll(releases) | |
| 784 | - | recent := mustLocalSession(t, d.sessions[domain.SessionID("recent")]) | |
| 785 | - | ||
| 786 | - | // Picker-style successful transition records its origin. | |
| 787 | - | require.NoError(t, d.switchToTarget(current, ac, picker.Target{Session: recent.id, TabIndex: -1})) | |
| 788 | - | for _, sess := range []*session{current, recent, current} { | |
| 789 | - | runPaletteCommand(t, d, ac.currentSession(), ac, "BSK") | |
| 790 | - | require.Same(t, sess, ac.currentSession()) | |
| 791 | - | } | |
| 792 | - | } | |
| 793 | - | ||
| 794 | - | func TestPaletteBackSessionDoesNotMoveWithoutValidTarget(t *testing.T) { | |
| 795 | - | d, current, ac, _, releases := newRecentNavigationTestSessions(t) | |
| 781 | + | func TestPaletteBackSessionSendsClientPreviousRouteAction(t *testing.T) { | |
| 782 | + | d, current, ac, sends, releases := newRecentNavigationTestSessions(t) | |
| 796 | 783 | defer releaseAll(releases) | |
| 797 | - | for _, tc := range []struct { | |
| 798 | - | name string | |
| 799 | - | prepare func() | |
| 800 | - | }{ | |
| 801 | - | {name: "no target", prepare: func() {}}, | |
| 802 | - | {name: "stale target", prepare: func() { ac.previousSession.Set("gone") }}, | |
| 803 | - | {name: "same session", prepare: func() { ac.previousSession.Set(current.id) }}, | |
| 804 | - | } { | |
| 805 | - | t.Run(tc.name, func(t *testing.T) { | |
| 806 | - | ac.clearPreviousSession() | |
| 807 | - | tc.prepare() | |
| 808 | - | runPaletteCommand(t, d, current, ac, "BSK") | |
| 809 | - | require.Same(t, current, ac.currentSession()) | |
| 810 | - | if tc.name != "no target" { | |
| 811 | - | require.Empty(t, ac.previousSession.Get(), "invalid target must not remain toggleable") | |
| 812 | - | } | |
| 813 | - | }) | |
| 814 | - | } | |
| 784 | + | transport := ac.transport() | |
| 785 | + | rc := d.attachCoordinator(current, nil, ac, true) | |
| 786 | + | token := current.attachmentToken(ac, transport) | |
| 787 | + | token.lease = rc.attachmentLease(ac) | |
| 788 | + | ac.publishAttachmentCapability(token) | |
| 789 | + | effect, admitted := ac.beginAttachmentEffect(token) | |
| 790 | + | require.True(t, admitted) | |
| 791 | + | defer effect.End() | |
| 792 | + | token.effect = effect | |
| 793 | + | ac.setRouteSnapshot(ports.RecentRouteSnapshot{ | |
| 794 | + | Generation: 3, | |
| 795 | + | Active: ports.RouteRef{Key: 3, Generation: 3}, | |
| 796 | + | Previous: ports.RouteRef{Key: 2, Generation: 2}, | |
| 797 | + | Entries: []ports.RecentRouteEntry{{Key: 2, Generation: 2, Name: "recent", Kind: ports.RouteKindLocal}}, | |
| 798 | + | }) | |
| 799 | + | ||
| 800 | + | require.NoError(t, d.backSessionForAttachment(token)) | |
| 801 | + | frame := awaitFrame(t, sends, ports.MsgNavigateRecentRoute) | |
| 802 | + | action, err := ports.UnmarshalRouteNavigationAction(frame.Payload) | |
| 803 | + | require.NoError(t, err) | |
| 804 | + | require.Equal(t, ports.RouteNavigationAction{SnapshotGeneration: 3, Key: 2, Generation: 2}, action) | |
| 815 | 805 | } | |
| 816 | 806 | ||
| 817 | - | func TestBackSessionReportsStaleHandoffOnce(t *testing.T) { | |
| 807 | + | func TestPaletteBackSessionWithoutClientHistoryIsNoop(t *testing.T) { | |
| 818 | 808 | d, current, ac, _, releases := newRecentNavigationTestSessions(t) | |
| 819 | 809 | defer releaseAll(releases) | |
| 820 | - | target := d.sessions[domain.SessionID("recent")] | |
| 821 | - | ac.previousSession.Set(target.id) | |
| 822 | - | ||
| 823 | - | // A displaced attachment makes the previously valid handoff stale by the | |
| 824 | - | // time switchToTarget commits it. | |
| 825 | - | current.mu.Lock() | |
| 826 | - | clearAttachmentsForTestLocked(current) | |
| 827 | - | current.mu.Unlock() | |
| 828 | 810 | ||
| 829 | 811 | d.backSession(current, ac) | |
| 830 | 812 | ||
| 831 | - | require.Same(t, current, ac.currentSession(), "a stale handoff must leave the attachment on its origin") | |
| 832 | - | require.Equal(t, target.id, ac.previousSession.Get(), "a failed handoff remains retryable") | |
| 833 | - | history := d.notices.history() | |
| 834 | - | require.Len(t, history, 1, "the switch failure must be reported exactly once") | |
| 835 | - | require.Equal(t, domain.NoticeSessionUnavailable, history[0].Code) | |
| 813 | + | require.Same(t, current, ac.currentSession()) | |
| 836 | 814 | } | |
| 837 | 815 | ||
| 838 | - | func TestStaleBackSessionClearPreservesConcurrentTarget(t *testing.T) { | |
| 839 | - | ac := &attachedClient{} | |
| 840 | - | stale := &session{sessionCore: sessionCore{id: "stale"}} | |
| 841 | - | updated := &session{sessionCore: sessionCore{id: "updated"}} | |
| 842 | - | ac.previousSession.Set(stale.id) | |
| 816 | + | func TestBackSessionWithoutSnapshotDoesNotReportDaemonHandoffFailure(t *testing.T) { | |
| 817 | + | d, current, ac, _, releases := newRecentNavigationTestSessions(t) | |
| 818 | + | defer releaseAll(releases) | |
| 843 | 819 | ||
| 844 | - | // Model a completed hand-off between observing a stale target and clearing | |
| 845 | - | // it. The conditional clear must not erase the newer toggle destination. | |
| 846 | - | ac.previousSession.Set(updated.id) | |
| 847 | - | ac.clearPreviousSessionIf(stale.id) | |
| 820 | + | d.backSession(current, ac) | |
| 848 | 821 | ||
| 849 | - | require.Equal(t, updated.id, ac.previousSession.Get()) | |
| 822 | + | require.Same(t, current, ac.currentSession()) | |
| 823 | + | require.Empty(t, d.notices.history()) | |
| 850 | 824 | } | |
| 851 | 825 | ||
| 852 | - | // backSession's invalid-target fallback is a render producer: it must publish | |
| 853 | - | // exactly one coordinator invalidation instead of composing directly, while | |
| 854 | - | // the valid-target hand-off (TestPaletteBackSessionTogglesPreviousSession) and | |
| 855 | - | // the guarded stale clear (TestStaleBackSessionClearPreservesConcurrentTarget) | |
| 856 | - | // keep their PR #73 behavior unchanged. | |
| 857 | - | func TestBackSessionInvalidTargetsFallBackThroughOneInvalidation(t *testing.T) { | |
| 858 | - | for _, tc := range []struct { | |
| 859 | - | name string | |
| 860 | - | prepare func(current *session, ac *attachedClient) | |
| 861 | - | }{ | |
| 862 | - | {name: "no previous target", prepare: func(*session, *attachedClient) {}}, | |
| 863 | - | {name: "stale previous target", prepare: func(_ *session, ac *attachedClient) { | |
| 864 | - | ac.previousSession.Set("gone") | |
| 865 | - | }}, | |
| 866 | - | {name: "previous target equals current", prepare: func(current *session, ac *attachedClient) { | |
| 867 | - | ac.previousSession.Set(current.id) | |
| 868 | - | }}, | |
| 869 | - | } { | |
| 870 | - | t.Run(tc.name, func(t *testing.T) { | |
| 871 | - | d, current, ac, sends, releases := newRecentNavigationTestSessions(t) | |
| 872 | - | defer releaseAll(releases) | |
| 873 | - | invs := make(chan renderInvalidation, 4) | |
| 874 | - | current.installRenderCoordinator(newRenderCoordinator(renderCoordinatorOptions{ | |
| 875 | - | clock: d.clock, | |
| 876 | - | wake: func(renderWake) {}, | |
| 877 | - | onInvalidate: func(inv renderInvalidation) { invs <- inv }, | |
| 878 | - | })) | |
| 879 | - | tc.prepare(current, ac) | |
| 880 | - | ||
| 881 | - | d.backSession(current, ac) | |
| 882 | - | ||
| 883 | - | require.Same(t, current, ac.currentSession(), "an invalid target must not move the attachment") | |
| 884 | - | awaitInvalidation(t, invs) | |
| 885 | - | requireNoInvalidation(t, invs) | |
| 886 | - | requireNoOutputFrame(t, sends) | |
| 887 | - | }) | |
| 888 | - | } | |
| 889 | - | } | |
| 826 | + | func TestBackSessionWithoutSnapshotDoesNotPublishFrames(t *testing.T) { | |
| 827 | + | d, current, ac, sends, releases := newRecentNavigationTestSessions(t) | |
| 828 | + | defer releaseAll(releases) | |
| 890 | 829 | ||
| 891 | - | func TestSwitchSourcePreviousSessionContracts(t *testing.T) { | |
| 892 | - | for _, tc := range []struct { | |
| 893 | - | name string | |
| 894 | - | target func(*session, *session) picker.Target | |
| 895 | - | wantCurrent string | |
| 896 | - | wantPrev string | |
| 897 | - | }{ | |
| 898 | - | { | |
| 899 | - | name: "picker cross-session switch records origin", | |
| 900 | - | target: func(_ *session, recent *session) picker.Target { | |
| 901 | - | return picker.Target{Session: recent.id, TabIndex: -1} | |
| 902 | - | }, | |
| 903 | - | wantCurrent: "recent", | |
| 904 | - | wantPrev: "current", | |
| 905 | - | }, | |
| 906 | - | { | |
| 907 | - | name: "attention cross-session switch records origin", | |
| 908 | - | target: func(_ *session, recent *session) picker.Target { | |
| 909 | - | return picker.Target{Session: recent.id, TabIndex: 0} | |
| 910 | - | }, | |
| 911 | - | wantCurrent: "recent", | |
| 912 | - | wantPrev: "current", | |
| 913 | - | }, | |
| 914 | - | { | |
| 915 | - | name: "same-session tab switch does not record", | |
| 916 | - | target: func(current *session, _ *session) picker.Target { | |
| 917 | - | return picker.Target{Session: current.id, TabIndex: 0} | |
| 918 | - | }, | |
| 919 | - | wantCurrent: "current", | |
| 920 | - | wantPrev: "", | |
| 921 | - | }, | |
| 922 | - | { | |
| 923 | - | name: "missing target does not record", | |
| 924 | - | target: func(_ *session, _ *session) picker.Target { | |
| 925 | - | return picker.Target{Session: "missing", TabIndex: -1} | |
| 926 | - | }, | |
| 927 | - | wantCurrent: "current", | |
| 928 | - | wantPrev: "", | |
| 929 | - | }, | |
| 930 | - | } { | |
| 931 | - | t.Run(tc.name, func(t *testing.T) { | |
| 932 | - | d, current, ac, _, releases := newRecentNavigationTestSessions(t) | |
| 933 | - | defer releaseAll(releases) | |
| 934 | - | recent := mustLocalSession(t, d.sessions[domain.SessionID("recent")]) | |
| 830 | + | d.backSession(current, ac) | |
| 935 | 831 | ||
| 936 | - | if tc.name == "missing target does not record" { | |
| 937 | - | require.Error(t, d.switchToTarget(current, ac, tc.target(current, recent))) | |
| 938 | - | } else { | |
| 939 | - | require.NoError(t, d.switchToTarget(current, ac, tc.target(current, recent))) | |
| 940 | - | } | |
| 941 | - | require.Equal(t, domain.SessionID(tc.wantCurrent), ac.currentSession().id) | |
| 942 | - | if tc.wantPrev == "" { | |
| 943 | - | require.Empty(t, ac.previousSession.Get()) | |
| 944 | - | return | |
| 945 | - | } | |
| 946 | - | require.Equal(t, domain.SessionID(tc.wantPrev), ac.previousSession.Get()) | |
| 947 | - | }) | |
| 948 | - | } | |
| 832 | + | require.Same(t, current, ac.currentSession()) | |
| 833 | + | requireNoOutputFrame(t, sends) | |
| 949 | 834 | } | |
| 950 | 835 | ||
| 951 | - | func TestTerminalTeardownClearsPreviousSession(t *testing.T) { | |
| 952 | - | for _, tc := range []struct { | |
| 953 | - | name string | |
| 954 | - | teardown func(*Daemon, *session, *attachedClient) | |
| 955 | - | }{ | |
| 956 | - | {name: "explicit detach", teardown: func(d *Daemon, sess *session, ac *attachedClient) { d.clientGone(sess, ac, ac.transport(), true) }}, | |
| 957 | - | {name: "non-park send error fallback", teardown: func(d *Daemon, sess *session, ac *attachedClient) { d.detachOnSendError(sess, ac, ac.transport()) }}, | |
| 958 | - | {name: "killed attached session", teardown: func(d *Daemon, sess *session, _ *attachedClient) { | |
| 959 | - | require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false)) | |
| 960 | - | }}, | |
| 961 | - | } { | |
| 962 | - | t.Run(tc.name, func(t *testing.T) { | |
| 963 | - | d, current, ac, _, releases := newRecentNavigationTestSessions(t) | |
| 964 | - | defer releaseAll(releases) | |
| 965 | - | ||
| 966 | - | ac.previousSession.Set(domain.SessionID("recent")) | |
| 967 | - | tc.teardown(d, current, ac) | |
| 836 | + | func TestSwitchSourceDoesNotOwnPreviousRoute(t *testing.T) { | |
| 837 | + | d, current, ac, _, releases := newRecentNavigationTestSessions(t) | |
| 838 | + | defer releaseAll(releases) | |
| 839 | + | recent := mustLocalSession(t, d.sessions[domain.SessionID("recent")]) | |
| 968 | 840 | ||
| 969 | - | require.Empty(t, ac.previousSession.Get()) | |
| 970 | - | }) | |
| 971 | - | } | |
| 841 | + | require.NoError(t, d.switchToTarget(current, ac, picker.Target{Session: recent.id, TabIndex: -1})) | |
| 842 | + | require.Same(t, recent, ac.currentSession()) | |
| 843 | + | require.Equal(t, uint64(0), ac.routeSnapshotCopy().Generation, "route ownership starts in the client") | |
| 972 | 844 | } | |
| 973 | 845 | ||
| 974 | 846 | func newRecentNavigationTestSessions(t *testing.T) (*Daemon, *session, *attachedClient, chan ports.Frame, []func()) { | |
| @@ -990,12 +862,6 @@ func newRecentNavigationTestSessions(t *testing.T) (*Daemon, *session, *attached | |||
| 990 | 862 | return d, current, ac, sends, []func(){release1, release2, release3} | |
| 991 | 863 | } | |
| 992 | 864 | ||
| 993 | - | func runPaletteCommand(t *testing.T, d *Daemon, sess *session, ac *attachedClient, code string) { | |
| 994 | - | t.Helper() | |
| 995 | - | d.handleInput(sess, ac, []byte("\x1b ")) | |
| 996 | - | d.handleInput(sess, ac, []byte(code+"\r")) | |
| 997 | - | } | |
| 998 | - | ||
| 999 | 865 | func releaseAll(releases []func()) { | |
| 1000 | 866 | for _, release := range releases { | |
| 1001 | 867 | release() | |
| @@ -778,197 +778,69 @@ func TestPaletteNextPreviousSwitchActiveTab(t *testing.T) { | |||||
| 778 | } | 778 | } | ||
| 779 | } | 779 | } | ||
| 780 | 780 | ||||
| 781 | - | func TestPaletteBackSessionTogglesPreviousSession(t *testing.T) { | 781 | + | func TestPaletteBackSessionSendsClientPreviousRouteAction(t *testing.T) { |
| 782 | - | d, current, ac, _, releases := newRecentNavigationTestSessions(t) | 782 | + | d, current, ac, sends, releases := newRecentNavigationTestSessions(t) |
| 783 | - | defer releaseAll(releases) | |||
| 784 | - | recent := mustLocalSession(t, d.sessions[domain.SessionID("recent")]) | |||
| 785 | - | ||||
| 786 | - | // Picker-style successful transition records its origin. | |||
| 787 | - | require.NoError(t, d.switchToTarget(current, ac, picker.Target{Session: recent.id, TabIndex: -1})) | |||
| 788 | - | for _, sess := range []*session{current, recent, current} { | |||
| 789 | - | runPaletteCommand(t, d, ac.currentSession(), ac, "BSK") | |||
| 790 | - | require.Same(t, sess, ac.currentSession()) | |||
| 791 | - | } | |||
| 792 | - | } | |||
| 793 | - | ||||
| 794 | - | func TestPaletteBackSessionDoesNotMoveWithoutValidTarget(t *testing.T) { | |||
| 795 | - | d, current, ac, _, releases := newRecentNavigationTestSessions(t) | |||
| 796 | defer releaseAll(releases) | 783 | defer releaseAll(releases) | ||
| 797 | - | for _, tc := range []struct { | 784 | + | transport := ac.transport() |
| 798 | - | name string | 785 | + | rc := d.attachCoordinator(current, nil, ac, true) |
| 799 | - | prepare func() | 786 | + | token := current.attachmentToken(ac, transport) |
| 800 | - | }{ | 787 | + | token.lease = rc.attachmentLease(ac) |
| 801 | - | {name: "no target", prepare: func() {}}, | 788 | + | ac.publishAttachmentCapability(token) |
| 802 | - | {name: "stale target", prepare: func() { ac.previousSession.Set("gone") }}, | 789 | + | effect, admitted := ac.beginAttachmentEffect(token) |
| 803 | - | {name: "same session", prepare: func() { ac.previousSession.Set(current.id) }}, | 790 | + | require.True(t, admitted) |
| 804 | - | } { | 791 | + | defer effect.End() |
| 805 | - | t.Run(tc.name, func(t *testing.T) { | 792 | + | token.effect = effect |
| 806 | - | ac.clearPreviousSession() | 793 | + | ac.setRouteSnapshot(ports.RecentRouteSnapshot{ |
| 807 | - | tc.prepare() | 794 | + | Generation: 3, |
| 808 | - | runPaletteCommand(t, d, current, ac, "BSK") | 795 | + | Active: ports.RouteRef{Key: 3, Generation: 3}, |
| 809 | - | require.Same(t, current, ac.currentSession()) | 796 | + | Previous: ports.RouteRef{Key: 2, Generation: 2}, |
| 810 | - | if tc.name != "no target" { | 797 | + | Entries: []ports.RecentRouteEntry{{Key: 2, Generation: 2, Name: "recent", Kind: ports.RouteKindLocal}}, |
| 811 | - | require.Empty(t, ac.previousSession.Get(), "invalid target must not remain toggleable") | 798 | + | }) |
| 812 | - | } | 799 | + | |
| 813 | - | }) | 800 | + | require.NoError(t, d.backSessionForAttachment(token)) |
| 814 | - | } | 801 | + | frame := awaitFrame(t, sends, ports.MsgNavigateRecentRoute) |
| 802 | + | action, err := ports.UnmarshalRouteNavigationAction(frame.Payload) | |||
| 803 | + | require.NoError(t, err) | |||
| 804 | + | require.Equal(t, ports.RouteNavigationAction{SnapshotGeneration: 3, Key: 2, Generation: 2}, action) | |||
| 815 | } | 805 | } | ||
| 816 | 806 | ||||
| 817 | - | func TestBackSessionReportsStaleHandoffOnce(t *testing.T) { | 807 | + | func TestPaletteBackSessionWithoutClientHistoryIsNoop(t *testing.T) { |
| 818 | d, current, ac, _, releases := newRecentNavigationTestSessions(t) | 808 | d, current, ac, _, releases := newRecentNavigationTestSessions(t) | ||
| 819 | defer releaseAll(releases) | 809 | defer releaseAll(releases) | ||
| 820 | - | target := d.sessions[domain.SessionID("recent")] | |||
| 821 | - | ac.previousSession.Set(target.id) | |||
| 822 | - | ||||
| 823 | - | // A displaced attachment makes the previously valid handoff stale by the | |||
| 824 | - | // time switchToTarget commits it. | |||
| 825 | - | current.mu.Lock() | |||
| 826 | - | clearAttachmentsForTestLocked(current) | |||
| 827 | - | current.mu.Unlock() | |||
| 828 | 810 | ||||
| 829 | d.backSession(current, ac) | 811 | d.backSession(current, ac) | ||
| 830 | 812 | ||||
| 831 | - | require.Same(t, current, ac.currentSession(), "a stale handoff must leave the attachment on its origin") | 813 | + | require.Same(t, current, ac.currentSession()) |
| 832 | - | require.Equal(t, target.id, ac.previousSession.Get(), "a failed handoff remains retryable") | |||
| 833 | - | history := d.notices.history() | |||
| 834 | - | require.Len(t, history, 1, "the switch failure must be reported exactly once") | |||
| 835 | - | require.Equal(t, domain.NoticeSessionUnavailable, history[0].Code) | |||
| 836 | } | 814 | } | ||
| 837 | 815 | ||||
| 838 | - | func TestStaleBackSessionClearPreservesConcurrentTarget(t *testing.T) { | 816 | + | func TestBackSessionWithoutSnapshotDoesNotReportDaemonHandoffFailure(t *testing.T) { |
| 839 | - | ac := &attachedClient{} | 817 | + | d, current, ac, _, releases := newRecentNavigationTestSessions(t) |
| 840 | - | stale := &session{sessionCore: sessionCore{id: "stale"}} | 818 | + | defer releaseAll(releases) |
| 841 | - | updated := &session{sessionCore: sessionCore{id: "updated"}} | |||
| 842 | - | ac.previousSession.Set(stale.id) | |||
| 843 | 819 | ||||
| 844 | - | // Model a completed hand-off between observing a stale target and clearing | 820 | + | d.backSession(current, ac) |
| 845 | - | // it. The conditional clear must not erase the newer toggle destination. | |||
| 846 | - | ac.previousSession.Set(updated.id) | |||
| 847 | - | ac.clearPreviousSessionIf(stale.id) | |||
| 848 | 821 | ||||
| 849 | - | require.Equal(t, updated.id, ac.previousSession.Get()) | 822 | + | require.Same(t, current, ac.currentSession()) |
| 823 | + | require.Empty(t, d.notices.history()) | |||
| 850 | } | 824 | } | ||
| 851 | 825 | ||||
| 852 | - | // backSession's invalid-target fallback is a render producer: it must publish | 826 | + | func TestBackSessionWithoutSnapshotDoesNotPublishFrames(t *testing.T) { |
| 853 | - | // exactly one coordinator invalidation instead of composing directly, while | 827 | + | d, current, ac, sends, releases := newRecentNavigationTestSessions(t) |
| 854 | - | // the valid-target hand-off (TestPaletteBackSessionTogglesPreviousSession) and | 828 | + | defer releaseAll(releases) |
| 855 | - | // the guarded stale clear (TestStaleBackSessionClearPreservesConcurrentTarget) | |||
| 856 | - | // keep their PR #73 behavior unchanged. | |||
| 857 | - | func TestBackSessionInvalidTargetsFallBackThroughOneInvalidation(t *testing.T) { | |||
| 858 | - | for _, tc := range []struct { | |||
| 859 | - | name string | |||
| 860 | - | prepare func(current *session, ac *attachedClient) | |||
| 861 | - | }{ | |||
| 862 | - | {name: "no previous target", prepare: func(*session, *attachedClient) {}}, | |||
| 863 | - | {name: "stale previous target", prepare: func(_ *session, ac *attachedClient) { | |||
| 864 | - | ac.previousSession.Set("gone") | |||
| 865 | - | }}, | |||
| 866 | - | {name: "previous target equals current", prepare: func(current *session, ac *attachedClient) { | |||
| 867 | - | ac.previousSession.Set(current.id) | |||
| 868 | - | }}, | |||
| 869 | - | } { | |||
| 870 | - | t.Run(tc.name, func(t *testing.T) { | |||
| 871 | - | d, current, ac, sends, releases := newRecentNavigationTestSessions(t) | |||
| 872 | - | defer releaseAll(releases) | |||
| 873 | - | invs := make(chan renderInvalidation, 4) | |||
| 874 | - | current.installRenderCoordinator(newRenderCoordinator(renderCoordinatorOptions{ | |||
| 875 | - | clock: d.clock, | |||
| 876 | - | wake: func(renderWake) {}, | |||
| 877 | - | onInvalidate: func(inv renderInvalidation) { invs <- inv }, | |||
| 878 | - | })) | |||
| 879 | - | tc.prepare(current, ac) | |||
| 880 | - | ||||
| 881 | - | d.backSession(current, ac) | |||
| 882 | - | ||||
| 883 | - | require.Same(t, current, ac.currentSession(), "an invalid target must not move the attachment") | |||
| 884 | - | awaitInvalidation(t, invs) | |||
| 885 | - | requireNoInvalidation(t, invs) | |||
| 886 | - | requireNoOutputFrame(t, sends) | |||
| 887 | - | }) | |||
| 888 | - | } | |||
| 889 | - | } | |||
| 890 | 829 | ||||
| 891 | - | func TestSwitchSourcePreviousSessionContracts(t *testing.T) { | 830 | + | d.backSession(current, ac) |
| 892 | - | for _, tc := range []struct { | |||
| 893 | - | name string | |||
| 894 | - | target func(*session, *session) picker.Target | |||
| 895 | - | wantCurrent string | |||
| 896 | - | wantPrev string | |||
| 897 | - | }{ | |||
| 898 | - | { | |||
| 899 | - | name: "picker cross-session switch records origin", | |||
| 900 | - | target: func(_ *session, recent *session) picker.Target { | |||
| 901 | - | return picker.Target{Session: recent.id, TabIndex: -1} | |||
| 902 | - | }, | |||
| 903 | - | wantCurrent: "recent", | |||
| 904 | - | wantPrev: "current", | |||
| 905 | - | }, | |||
| 906 | - | { | |||
| 907 | - | name: "attention cross-session switch records origin", | |||
| 908 | - | target: func(_ *session, recent *session) picker.Target { | |||
| 909 | - | return picker.Target{Session: recent.id, TabIndex: 0} | |||
| 910 | - | }, | |||
| 911 | - | wantCurrent: "recent", | |||
| 912 | - | wantPrev: "current", | |||
| 913 | - | }, | |||
| 914 | - | { | |||
| 915 | - | name: "same-session tab switch does not record", | |||
| 916 | - | target: func(current *session, _ *session) picker.Target { | |||
| 917 | - | return picker.Target{Session: current.id, TabIndex: 0} | |||
| 918 | - | }, | |||
| 919 | - | wantCurrent: "current", | |||
| 920 | - | wantPrev: "", | |||
| 921 | - | }, | |||
| 922 | - | { | |||
| 923 | - | name: "missing target does not record", | |||
| 924 | - | target: func(_ *session, _ *session) picker.Target { | |||
| 925 | - | return picker.Target{Session: "missing", TabIndex: -1} | |||
| 926 | - | }, | |||
| 927 | - | wantCurrent: "current", | |||
| 928 | - | wantPrev: "", | |||
| 929 | - | }, | |||
| 930 | - | } { | |||
| 931 | - | t.Run(tc.name, func(t *testing.T) { | |||
| 932 | - | d, current, ac, _, releases := newRecentNavigationTestSessions(t) | |||
| 933 | - | defer releaseAll(releases) | |||
| 934 | - | recent := mustLocalSession(t, d.sessions[domain.SessionID("recent")]) | |||
| 935 | 831 | ||||
| 936 | - | if tc.name == "missing target does not record" { | 832 | + | require.Same(t, current, ac.currentSession()) |
| 937 | - | require.Error(t, d.switchToTarget(current, ac, tc.target(current, recent))) | 833 | + | requireNoOutputFrame(t, sends) |
| 938 | - | } else { | |||
| 939 | - | require.NoError(t, d.switchToTarget(current, ac, tc.target(current, recent))) | |||
| 940 | - | } | |||
| 941 | - | require.Equal(t, domain.SessionID(tc.wantCurrent), ac.currentSession().id) | |||
| 942 | - | if tc.wantPrev == "" { | |||
| 943 | - | require.Empty(t, ac.previousSession.Get()) | |||
| 944 | - | return | |||
| 945 | - | } | |||
| 946 | - | require.Equal(t, domain.SessionID(tc.wantPrev), ac.previousSession.Get()) | |||
| 947 | - | }) | |||
| 948 | - | } | |||
| 949 | } | 834 | } | ||
| 950 | 835 | ||||
| 951 | - | func TestTerminalTeardownClearsPreviousSession(t *testing.T) { | 836 | + | func TestSwitchSourceDoesNotOwnPreviousRoute(t *testing.T) { |
| 952 | - | for _, tc := range []struct { | 837 | + | d, current, ac, _, releases := newRecentNavigationTestSessions(t) |
| 953 | - | name string | 838 | + | defer releaseAll(releases) |
| 954 | - | teardown func(*Daemon, *session, *attachedClient) | 839 | + | recent := mustLocalSession(t, d.sessions[domain.SessionID("recent")]) |
| 955 | - | }{ | |||
| 956 | - | {name: "explicit detach", teardown: func(d *Daemon, sess *session, ac *attachedClient) { d.clientGone(sess, ac, ac.transport(), true) }}, | |||
| 957 | - | {name: "non-park send error fallback", teardown: func(d *Daemon, sess *session, ac *attachedClient) { d.detachOnSendError(sess, ac, ac.transport()) }}, | |||
| 958 | - | {name: "killed attached session", teardown: func(d *Daemon, sess *session, _ *attachedClient) { | |||
| 959 | - | require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false)) | |||
| 960 | - | }}, | |||
| 961 | - | } { | |||
| 962 | - | t.Run(tc.name, func(t *testing.T) { | |||
| 963 | - | d, current, ac, _, releases := newRecentNavigationTestSessions(t) | |||
| 964 | - | defer releaseAll(releases) | |||
| 965 | - | ||||
| 966 | - | ac.previousSession.Set(domain.SessionID("recent")) | |||
| 967 | - | tc.teardown(d, current, ac) | |||
| 968 | 840 | ||||
| 969 | - | require.Empty(t, ac.previousSession.Get()) | 841 | + | require.NoError(t, d.switchToTarget(current, ac, picker.Target{Session: recent.id, TabIndex: -1})) |
| 970 | - | }) | 842 | + | require.Same(t, recent, ac.currentSession()) |
| 971 | - | } | 843 | + | require.Equal(t, uint64(0), ac.routeSnapshotCopy().Generation, "route ownership starts in the client") |
| 972 | } | 844 | } | ||
| 973 | 845 | ||||
| 974 | func newRecentNavigationTestSessions(t *testing.T) (*Daemon, *session, *attachedClient, chan ports.Frame, []func()) { | 846 | func newRecentNavigationTestSessions(t *testing.T) (*Daemon, *session, *attachedClient, chan ports.Frame, []func()) { | ||
| @@ -990,12 +862,6 @@ func newRecentNavigationTestSessions(t *testing.T) (*Daemon, *session, *attached | |||||
| 990 | return d, current, ac, sends, []func(){release1, release2, release3} | 862 | return d, current, ac, sends, []func(){release1, release2, release3} | ||
| 991 | } | 863 | } | ||
| 992 | 864 | ||||
| 993 | - | func runPaletteCommand(t *testing.T, d *Daemon, sess *session, ac *attachedClient, code string) { | |||
| 994 | - | t.Helper() | |||
| 995 | - | d.handleInput(sess, ac, []byte("\x1b ")) | |||
| 996 | - | d.handleInput(sess, ac, []byte(code+"\r")) | |||
| 997 | - | } | |||
| 998 | - | ||||
| 999 | func releaseAll(releases []func()) { | 865 | func releaseAll(releases []func()) { | ||
| 1000 | for _, release := range releases { | 866 | for _, release := range releases { | ||
| 1001 | release() | 867 | release() | ||
M internal/usecase/daemon/move_pane_postcommit.go +0 -1
| @@ -116,7 +116,6 @@ func (p movePanePostcommitPlan) execute(d *Daemon) { | |||
| 116 | 116 | } | |
| 117 | 117 | for _, attachment := range p.retiredAttachments { | |
| 118 | 118 | d.unregisterPreview(attachment.ac) | |
| 119 | - | attachment.ac.clearPreviousSession() | |
| 120 | 119 | attachment.ac.clearCaptureFrames() | |
| 121 | 120 | d.notifyDetachedSnapshotAsync(attachment, ports.ReasonSessionKilled) | |
| 122 | 121 | } | |
| @@ -116,7 +116,6 @@ func (p movePanePostcommitPlan) execute(d *Daemon) { | |||||
| 116 | } | 116 | } | ||
| 117 | for _, attachment := range p.retiredAttachments { | 117 | for _, attachment := range p.retiredAttachments { | ||
| 118 | d.unregisterPreview(attachment.ac) | 118 | d.unregisterPreview(attachment.ac) | ||
| 119 | - | attachment.ac.clearPreviousSession() | |||
| 120 | attachment.ac.clearCaptureFrames() | 119 | attachment.ac.clearCaptureFrames() | ||
| 121 | d.notifyDetachedSnapshotAsync(attachment, ports.ReasonSessionKilled) | 120 | d.notifyDetachedSnapshotAsync(attachment, ports.ReasonSessionKilled) | ||
| 122 | } | 121 | } | ||
M internal/usecase/daemon/navigation_attachment_handoff_test.go +0 -6
| @@ -32,12 +32,6 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) { | |||
| 32 | 32 | return d.switchToTargetForAttachment(effect.connectionToken(), picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "palette-session") | |
| 33 | 33 | }, | |
| 34 | 34 | }, | |
| 35 | - | { | |
| 36 | - | name: "palette recent session", action: "palette-recent-session", | |
| 37 | - | run: func(d *Daemon, source, target *session, ac *attachedClient, effect *attachmentEffectTicket) error { | |
| 38 | - | return paletteExec{d: d, sess: source, ac: ac, recent: []recentSession{{id: target.id}}, effect: effect}.JumpRecentSession(1) | |
| 39 | - | }, | |
| 40 | - | }, | |
| 41 | 35 | { | |
| 42 | 36 | name: "session overflow", action: "overflow-session", | |
| 43 | 37 | run: func(d *Daemon, source, target *session, ac *attachedClient, effect *attachmentEffectTicket) error { | |
| @@ -32,12 +32,6 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) { | |||||
| 32 | return d.switchToTargetForAttachment(effect.connectionToken(), picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "palette-session") | 32 | return d.switchToTargetForAttachment(effect.connectionToken(), picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "palette-session") | ||
| 33 | }, | 33 | }, | ||
| 34 | }, | 34 | }, | ||
| 35 | - | { | |||
| 36 | - | name: "palette recent session", action: "palette-recent-session", | |||
| 37 | - | run: func(d *Daemon, source, target *session, ac *attachedClient, effect *attachmentEffectTicket) error { | |||
| 38 | - | return paletteExec{d: d, sess: source, ac: ac, recent: []recentSession{{id: target.id}}, effect: effect}.JumpRecentSession(1) | |||
| 39 | - | }, | |||
| 40 | - | }, | |||
| 41 | { | 35 | { | ||
| 42 | name: "session overflow", action: "overflow-session", | 36 | name: "session overflow", action: "overflow-session", | ||
| 43 | run: func(d *Daemon, source, target *session, ac *attachedClient, effect *attachmentEffectTicket) error { | 37 | run: func(d *Daemon, source, target *session, ac *attachedClient, effect *attachmentEffectTicket) error { | ||
M internal/usecase/daemon/overlay_runtime.go +14 -12
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "time" | |
| 7 | 7 | ||
| 8 | 8 | "github.com/bnema/vev/internal/domain" | |
| 9 | + | "github.com/bnema/vev/internal/ports" | |
| 9 | 10 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 10 | 11 | "github.com/bnema/vev/internal/usecase/notices" | |
| 11 | 12 | "github.com/bnema/vev/internal/usecase/palette" | |
| @@ -40,13 +41,13 @@ type overlayRuntime struct { | |||
| 40 | 41 | beforeRemotePickerRegistration func() | |
| 41 | 42 | afterPickerRefreshBuild func(*picker.Model) | |
| 42 | 43 | ||
| 43 | - | paletteMu sync.Mutex | |
| 44 | - | palette *palette.Model | |
| 45 | - | paletteRecent []recentSession // immutable for this palette interaction | |
| 46 | - | paletteGeneration uint64 | |
| 47 | - | paletteHints palette.ContextualHints | |
| 48 | - | paletteFeedback string | |
| 49 | - | palettePending []byte | |
| 44 | + | paletteMu sync.Mutex | |
| 45 | + | palette *palette.Model | |
| 46 | + | paletteRouteSnapshot ports.RecentRouteSnapshot | |
| 47 | + | paletteGeneration uint64 | |
| 48 | + | paletteHints palette.ContextualHints | |
| 49 | + | paletteFeedback string | |
| 50 | + | palettePending []byte | |
| 50 | 51 | ||
| 51 | 52 | promptMu sync.Mutex | |
| 52 | 53 | prompt *promptui.Model | |
| @@ -304,10 +305,10 @@ type overlayRenderSnapshot struct { | |||
| 304 | 305 | paletteModel *palette.Model | |
| 305 | 306 | // paletteHints is a copy captured under paletteMu. Rendering must use this | |
| 306 | 307 | // immutable interaction snapshot rather than consult live session state. | |
| 307 | - | paletteHints *palette.ContextualHints | |
| 308 | - | paletteFeedback string | |
| 309 | - | paletteRecent []recentSession | |
| 310 | - | paletteLocked bool | |
| 308 | + | paletteHints *palette.ContextualHints | |
| 309 | + | paletteFeedback string | |
| 310 | + | paletteRouteSnapshot ports.RecentRouteSnapshot | |
| 311 | + | paletteLocked bool | |
| 311 | 312 | ||
| 312 | 313 | promptActive bool | |
| 313 | 314 | promptModel *promptui.Model | |
| @@ -378,7 +379,8 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot { | |||
| 378 | 379 | hints.Recent = append([]palette.RecentSessionHint(nil), hints.Recent...) | |
| 379 | 380 | snap.paletteHints = &hints | |
| 380 | 381 | snap.paletteFeedback = rt.paletteFeedback | |
| 381 | - | snap.paletteRecent = append([]recentSession(nil), rt.paletteRecent...) | |
| 382 | + | snap.paletteRouteSnapshot = rt.paletteRouteSnapshot | |
| 383 | + | snap.paletteRouteSnapshot.Entries = append([]ports.RecentRouteEntry(nil), rt.paletteRouteSnapshot.Entries...) | |
| 382 | 384 | snap.paletteLocked = true | |
| 383 | 385 | } else { | |
| 384 | 386 | rt.paletteMu.Unlock() | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "time" | 6 | "time" | ||
| 7 | 7 | ||||
| 8 | "github.com/bnema/vev/internal/domain" | 8 | "github.com/bnema/vev/internal/domain" | ||
| 9 | + | "github.com/bnema/vev/internal/ports" | |||
| 9 | scopy "github.com/bnema/vev/internal/usecase/copy" | 10 | scopy "github.com/bnema/vev/internal/usecase/copy" | ||
| 10 | "github.com/bnema/vev/internal/usecase/notices" | 11 | "github.com/bnema/vev/internal/usecase/notices" | ||
| 11 | "github.com/bnema/vev/internal/usecase/palette" | 12 | "github.com/bnema/vev/internal/usecase/palette" | ||
| @@ -40,13 +41,13 @@ type overlayRuntime struct { | |||||
| 40 | beforeRemotePickerRegistration func() | 41 | beforeRemotePickerRegistration func() | ||
| 41 | afterPickerRefreshBuild func(*picker.Model) | 42 | afterPickerRefreshBuild func(*picker.Model) | ||
| 42 | 43 | ||||
| 43 | - | paletteMu sync.Mutex | 44 | + | paletteMu sync.Mutex |
| 44 | - | palette *palette.Model | 45 | + | palette *palette.Model |
| 45 | - | paletteRecent []recentSession // immutable for this palette interaction | 46 | + | paletteRouteSnapshot ports.RecentRouteSnapshot |
| 46 | - | paletteGeneration uint64 | 47 | + | paletteGeneration uint64 |
| 47 | - | paletteHints palette.ContextualHints | 48 | + | paletteHints palette.ContextualHints |
| 48 | - | paletteFeedback string | 49 | + | paletteFeedback string |
| 49 | - | palettePending []byte | 50 | + | palettePending []byte |
| 50 | 51 | ||||
| 51 | promptMu sync.Mutex | 52 | promptMu sync.Mutex | ||
| 52 | prompt *promptui.Model | 53 | prompt *promptui.Model | ||
| @@ -304,10 +305,10 @@ type overlayRenderSnapshot struct { | |||||
| 304 | paletteModel *palette.Model | 305 | paletteModel *palette.Model | ||
| 305 | // paletteHints is a copy captured under paletteMu. Rendering must use this | 306 | // paletteHints is a copy captured under paletteMu. Rendering must use this | ||
| 306 | // immutable interaction snapshot rather than consult live session state. | 307 | // immutable interaction snapshot rather than consult live session state. | ||
| 307 | - | paletteHints *palette.ContextualHints | 308 | + | paletteHints *palette.ContextualHints |
| 308 | - | paletteFeedback string | 309 | + | paletteFeedback string |
| 309 | - | paletteRecent []recentSession | 310 | + | paletteRouteSnapshot ports.RecentRouteSnapshot |
| 310 | - | paletteLocked bool | 311 | + | paletteLocked bool |
| 311 | 312 | ||||
| 312 | promptActive bool | 313 | promptActive bool | ||
| 313 | promptModel *promptui.Model | 314 | promptModel *promptui.Model | ||
| @@ -378,7 +379,8 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot { | |||||
| 378 | hints.Recent = append([]palette.RecentSessionHint(nil), hints.Recent...) | 379 | hints.Recent = append([]palette.RecentSessionHint(nil), hints.Recent...) | ||
| 379 | snap.paletteHints = &hints | 380 | snap.paletteHints = &hints | ||
| 380 | snap.paletteFeedback = rt.paletteFeedback | 381 | snap.paletteFeedback = rt.paletteFeedback | ||
| 381 | - | snap.paletteRecent = append([]recentSession(nil), rt.paletteRecent...) | 382 | + | snap.paletteRouteSnapshot = rt.paletteRouteSnapshot |
| 383 | + | snap.paletteRouteSnapshot.Entries = append([]ports.RecentRouteEntry(nil), rt.paletteRouteSnapshot.Entries...) | |||
| 382 | snap.paletteLocked = true | 384 | snap.paletteLocked = true | ||
| 383 | } else { | 385 | } else { | ||
| 384 | rt.paletteMu.Unlock() | 386 | rt.paletteMu.Unlock() | ||
M internal/usecase/daemon/palette.go +39 -34
| @@ -37,15 +37,16 @@ func paletteModalFor(size domain.Size, cfg domain.PaletteConfig) ui.Modal { | |||
| 37 | 37 | } | |
| 38 | 38 | ||
| 39 | 39 | func (d *Daemon) enterPalette(sess *session, ac *attachedClient) { | |
| 40 | - | // Capture daemon/session state before taking paletteMu: lock ordering forbids | |
| 41 | - | // holding an overlay lock while inspecting live sessions. | |
| 42 | - | recent := d.recentSessions(sess) | |
| 40 | + | // Capture the client-owned route snapshot before taking paletteMu. The | |
| 41 | + | // snapshot is immutable for this palette interaction and the daemon never | |
| 42 | + | // consults its own session history for recent-route commands. | |
| 43 | + | routeSnapshot := ac.routeSnapshotCopy() | |
| 43 | 44 | commands := d.paletteCommands() | |
| 44 | 45 | results := d.paletteResults(sess, commands) | |
| 45 | 46 | ac.overlays.paletteMu.Lock() | |
| 46 | 47 | ac.overlays.paletteGeneration++ | |
| 47 | 48 | ac.overlays.palette = palette.New(results) | |
| 48 | - | ac.overlays.paletteRecent = recent | |
| 49 | + | ac.overlays.paletteRouteSnapshot = routeSnapshot | |
| 49 | 50 | ac.overlays.paletteHints = palette.ContextualHints{} | |
| 50 | 51 | ac.overlays.paletteFeedback = "" | |
| 51 | 52 | ac.overlays.palettePending = nil | |
| @@ -93,13 +94,22 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command) [] | |||
| 93 | 94 | return results | |
| 94 | 95 | } | |
| 95 | 96 | ||
| 96 | - | func recentSessionHints(recent []recentSession, args []string) palette.ContextualHints { | |
| 97 | - | formatted := formatRecentRoutePresentations(recentRoutePresentations(recent)) | |
| 97 | + | func recentRouteHints(snapshot ports.RecentRouteSnapshot, args []string) palette.ContextualHints { | |
| 98 | + | formatted := formatRecentRouteSnapshot(snapshot) | |
| 98 | 99 | names := make([]string, len(formatted)) | |
| 99 | 100 | for i, entry := range formatted { | |
| 100 | 101 | names[i] = entry.name | |
| 101 | 102 | } | |
| 102 | - | return palette.BuildRecentSessionHints(names, args) | |
| 103 | + | hints := palette.BuildRecentSessionHints(names, args) | |
| 104 | + | for i, entry := range snapshot.Entries { | |
| 105 | + | if i >= len(hints.Recent) { | |
| 106 | + | break | |
| 107 | + | } | |
| 108 | + | hints.Recent[i].SnapshotGeneration = snapshot.Generation | |
| 109 | + | hints.Recent[i].Key = entry.Key | |
| 110 | + | hints.Recent[i].Generation = entry.Generation | |
| 111 | + | } | |
| 112 | + | return hints | |
| 103 | 113 | } | |
| 104 | 114 | ||
| 105 | 115 | func (d *Daemon) paletteCommands() []command.Command { | |
| @@ -157,7 +167,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 157 | 167 | var sessionTarget palette.Result | |
| 158 | 168 | var hasSessionTarget bool | |
| 159 | 169 | var args []string | |
| 160 | - | var recent []recentSession | |
| 170 | + | var routeSnapshot ports.RecentRouteSnapshot | |
| 161 | 171 | var generation uint64 | |
| 162 | 172 | var rawQuery string | |
| 163 | 173 | changed, cancel, execute := false, false, false | |
| @@ -228,7 +238,8 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 228 | 238 | return | |
| 229 | 239 | } | |
| 230 | 240 | args = action.Args | |
| 231 | - | recent = append([]recentSession(nil), ac.overlays.paletteRecent...) | |
| 241 | + | routeSnapshot = ac.overlays.paletteRouteSnapshot | |
| 242 | + | routeSnapshot.Entries = append([]ports.RecentRouteEntry(nil), routeSnapshot.Entries...) | |
| 232 | 243 | } | |
| 233 | 244 | } else if _, ok := selected.SessionName(); ok { | |
| 234 | 245 | sessionTarget = selected | |
| @@ -245,7 +256,12 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 245 | 256 | ac.overlays.paletteFeedback = "" | |
| 246 | 257 | active, ok := ac.overlays.palette.ArgumentCommand() | |
| 247 | 258 | if ok && active.Slug == "jump-recent-session" { | |
| 248 | - | ac.overlays.paletteHints = recentSessionHints(ac.overlays.paletteRecent, paletteArgs(ac.overlays.palette.Query(), active)) | |
| 259 | + | args := paletteArgs(ac.overlays.palette.Query(), active) | |
| 260 | + | if ac.overlays.paletteRouteSnapshot.Generation != 0 { | |
| 261 | + | ac.overlays.paletteHints = recentRouteHints(ac.overlays.paletteRouteSnapshot, args) | |
| 262 | + | } else { | |
| 263 | + | ac.overlays.paletteHints = palette.ContextualHints{} | |
| 264 | + | } | |
| 249 | 265 | } else { | |
| 250 | 266 | ac.overlays.paletteHints = palette.ContextualHints{} | |
| 251 | 267 | } | |
| @@ -304,7 +320,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 304 | 320 | d.invalidateRender(entry, ac, true, "palette.go") | |
| 305 | 321 | return | |
| 306 | 322 | } | |
| 307 | - | exec := paletteExec{d: d, sess: sess, attachment: entry, ac: ac, recent: recent, effect: effect} | |
| 323 | + | exec := paletteExec{d: d, sess: sess, attachment: entry, ac: ac, routeSnapshot: routeSnapshot, effect: effect} | |
| 308 | 324 | if err := exec.JumpRecentSession(rank); err != nil { | |
| 309 | 325 | if errors.Is(err, errAttachmentTransition) { | |
| 310 | 326 | return | |
| @@ -371,7 +387,7 @@ func paletteArgs(query string, cmd command.Command) []string { | |||
| 371 | 387 | func (ac *attachedClient) clearPaletteLocked() { | |
| 372 | 388 | ac.overlays.paletteGeneration++ | |
| 373 | 389 | ac.overlays.palette = nil | |
| 374 | - | ac.overlays.paletteRecent = nil | |
| 390 | + | ac.overlays.paletteRouteSnapshot = ports.RecentRouteSnapshot{} | |
| 375 | 391 | ac.overlays.paletteHints = palette.ContextualHints{} | |
| 376 | 392 | ac.overlays.paletteFeedback = "" | |
| 377 | 393 | ac.overlays.palettePending = nil | |
| @@ -401,7 +417,7 @@ type paletteExec struct { | |||
| 401 | 417 | sess *session | |
| 402 | 418 | attachment *session | |
| 403 | 419 | ac *attachedClient | |
| 404 | - | recent []recentSession | |
| 420 | + | routeSnapshot ports.RecentRouteSnapshot | |
| 405 | 421 | actions daemonActionRunner | |
| 406 | 422 | effect *attachmentEffectTicket | |
| 407 | 423 | redrawClosedPalette bool | |
| @@ -634,36 +650,25 @@ func (e paletteExec) YankLastNotification() error { | |||
| 634 | 650 | } | |
| 635 | 651 | ||
| 636 | 652 | func (e paletteExec) JumpRecentSession(rank int) error { | |
| 637 | - | if rank < 1 || rank > len(e.recent) { | |
| 653 | + | if e.routeSnapshot.Generation == 0 || rank < 1 || rank > len(e.routeSnapshot.Entries) { | |
| 638 | 654 | return command.ErrInvalidArguments | |
| 639 | 655 | } | |
| 640 | - | target := e.recent[rank-1] | |
| 641 | - | if e.d.sessionByID(target.id) == nil { | |
| 642 | - | return command.ErrInvalidArguments | |
| 656 | + | entry := e.routeSnapshot.Entries[rank-1] | |
| 657 | + | action := ports.RouteNavigationAction{ | |
| 658 | + | SnapshotGeneration: e.routeSnapshot.Generation, | |
| 659 | + | Key: entry.Key, | |
| 660 | + | Generation: entry.Generation, | |
| 643 | 661 | } | |
| 644 | 662 | if e.d.beforeRecentSessionHandoff != nil { | |
| 645 | 663 | e.d.beforeRecentSessionHandoff() | |
| 646 | 664 | } | |
| 647 | - | var err error | |
| 648 | 665 | if e.effect != nil { | |
| 649 | - | err = e.d.switchToTargetForAttachment(e.effect.connectionToken(), picker.Target{Session: target.id, TabIndex: -1}, sessionHandoffGuard{}, "palette-recent-session") | |
| 650 | - | } else { | |
| 651 | - | sess := e.attachment | |
| 652 | - | if sess == nil { | |
| 653 | - | sess = e.sess | |
| 654 | - | } | |
| 655 | - | if sess == nil { | |
| 656 | - | return errAttachmentTransition | |
| 657 | - | } | |
| 658 | - | err = e.d.switchToTarget(sess, e.ac, picker.Target{Session: target.id, TabIndex: -1}) | |
| 666 | + | return e.d.sendRecentRouteNavigationActionForAttachment(e.effect.connectionToken(), action) | |
| 659 | 667 | } | |
| 660 | - | if e.effect != nil && errors.Is(err, errAttachmentTransition) { | |
| 661 | - | return err | |
| 668 | + | if e.ac == nil || e.attachment == nil { | |
| 669 | + | return errAttachmentTransition | |
| 662 | 670 | } | |
| 663 | - | if err != nil { | |
| 664 | - | return command.ErrInvalidArguments | |
| 665 | - | } | |
| 666 | - | return nil | |
| 671 | + | return e.d.sendRecentRouteNavigationActionForAttachment(e.attachment.attachmentToken(e.ac, e.ac.transport()), action) | |
| 667 | 672 | } | |
| 668 | 673 | ||
| 669 | 674 | func composePaletteClientFrame(model *palette.Model, base renderer.Frame, cfg domain.PaletteConfig, guidance string, styles ...themeui.Styles) (renderer.Frame, []renderer.Damage) { | |
| @@ -37,15 +37,16 @@ func paletteModalFor(size domain.Size, cfg domain.PaletteConfig) ui.Modal { | |||||
| 37 | } | 37 | } | ||
| 38 | 38 | ||||
| 39 | func (d *Daemon) enterPalette(sess *session, ac *attachedClient) { | 39 | func (d *Daemon) enterPalette(sess *session, ac *attachedClient) { | ||
| 40 | - | // Capture daemon/session state before taking paletteMu: lock ordering forbids | 40 | + | // Capture the client-owned route snapshot before taking paletteMu. The |
| 41 | - | // holding an overlay lock while inspecting live sessions. | 41 | + | // snapshot is immutable for this palette interaction and the daemon never |
| 42 | - | recent := d.recentSessions(sess) | 42 | + | // consults its own session history for recent-route commands. |
| 43 | + | routeSnapshot := ac.routeSnapshotCopy() | |||
| 43 | commands := d.paletteCommands() | 44 | commands := d.paletteCommands() | ||
| 44 | results := d.paletteResults(sess, commands) | 45 | results := d.paletteResults(sess, commands) | ||
| 45 | ac.overlays.paletteMu.Lock() | 46 | ac.overlays.paletteMu.Lock() | ||
| 46 | ac.overlays.paletteGeneration++ | 47 | ac.overlays.paletteGeneration++ | ||
| 47 | ac.overlays.palette = palette.New(results) | 48 | ac.overlays.palette = palette.New(results) | ||
| 48 | - | ac.overlays.paletteRecent = recent | 49 | + | ac.overlays.paletteRouteSnapshot = routeSnapshot |
| 49 | ac.overlays.paletteHints = palette.ContextualHints{} | 50 | ac.overlays.paletteHints = palette.ContextualHints{} | ||
| 50 | ac.overlays.paletteFeedback = "" | 51 | ac.overlays.paletteFeedback = "" | ||
| 51 | ac.overlays.palettePending = nil | 52 | ac.overlays.palettePending = nil | ||
| @@ -93,13 +94,22 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command) [] | |||||
| 93 | return results | 94 | return results | ||
| 94 | } | 95 | } | ||
| 95 | 96 | ||||
| 96 | - | func recentSessionHints(recent []recentSession, args []string) palette.ContextualHints { | 97 | + | func recentRouteHints(snapshot ports.RecentRouteSnapshot, args []string) palette.ContextualHints { |
| 97 | - | formatted := formatRecentRoutePresentations(recentRoutePresentations(recent)) | 98 | + | formatted := formatRecentRouteSnapshot(snapshot) |
| 98 | names := make([]string, len(formatted)) | 99 | names := make([]string, len(formatted)) | ||
| 99 | for i, entry := range formatted { | 100 | for i, entry := range formatted { | ||
| 100 | names[i] = entry.name | 101 | names[i] = entry.name | ||
| 101 | } | 102 | } | ||
| 102 | - | return palette.BuildRecentSessionHints(names, args) | 103 | + | hints := palette.BuildRecentSessionHints(names, args) |
| 104 | + | for i, entry := range snapshot.Entries { | |||
| 105 | + | if i >= len(hints.Recent) { | |||
| 106 | + | break | |||
| 107 | + | } | |||
| 108 | + | hints.Recent[i].SnapshotGeneration = snapshot.Generation | |||
| 109 | + | hints.Recent[i].Key = entry.Key | |||
| 110 | + | hints.Recent[i].Generation = entry.Generation | |||
| 111 | + | } | |||
| 112 | + | return hints | |||
| 103 | } | 113 | } | ||
| 104 | 114 | ||||
| 105 | func (d *Daemon) paletteCommands() []command.Command { | 115 | func (d *Daemon) paletteCommands() []command.Command { | ||
| @@ -157,7 +167,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||||
| 157 | var sessionTarget palette.Result | 167 | var sessionTarget palette.Result | ||
| 158 | var hasSessionTarget bool | 168 | var hasSessionTarget bool | ||
| 159 | var args []string | 169 | var args []string | ||
| 160 | - | var recent []recentSession | 170 | + | var routeSnapshot ports.RecentRouteSnapshot |
| 161 | var generation uint64 | 171 | var generation uint64 | ||
| 162 | var rawQuery string | 172 | var rawQuery string | ||
| 163 | changed, cancel, execute := false, false, false | 173 | changed, cancel, execute := false, false, false | ||
| @@ -228,7 +238,8 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||||
| 228 | return | 238 | return | ||
| 229 | } | 239 | } | ||
| 230 | args = action.Args | 240 | args = action.Args | ||
| 231 | - | recent = append([]recentSession(nil), ac.overlays.paletteRecent...) | 241 | + | routeSnapshot = ac.overlays.paletteRouteSnapshot |
| 242 | + | routeSnapshot.Entries = append([]ports.RecentRouteEntry(nil), routeSnapshot.Entries...) | |||
| 232 | } | 243 | } | ||
| 233 | } else if _, ok := selected.SessionName(); ok { | 244 | } else if _, ok := selected.SessionName(); ok { | ||
| 234 | sessionTarget = selected | 245 | sessionTarget = selected | ||
| @@ -245,7 +256,12 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||||
| 245 | ac.overlays.paletteFeedback = "" | 256 | ac.overlays.paletteFeedback = "" | ||
| 246 | active, ok := ac.overlays.palette.ArgumentCommand() | 257 | active, ok := ac.overlays.palette.ArgumentCommand() | ||
| 247 | if ok && active.Slug == "jump-recent-session" { | 258 | if ok && active.Slug == "jump-recent-session" { | ||
| 248 | - | ac.overlays.paletteHints = recentSessionHints(ac.overlays.paletteRecent, paletteArgs(ac.overlays.palette.Query(), active)) | 259 | + | args := paletteArgs(ac.overlays.palette.Query(), active) |
| 260 | + | if ac.overlays.paletteRouteSnapshot.Generation != 0 { | |||
| 261 | + | ac.overlays.paletteHints = recentRouteHints(ac.overlays.paletteRouteSnapshot, args) | |||
| 262 | + | } else { | |||
| 263 | + | ac.overlays.paletteHints = palette.ContextualHints{} | |||
| 264 | + | } | |||
| 249 | } else { | 265 | } else { | ||
| 250 | ac.overlays.paletteHints = palette.ContextualHints{} | 266 | ac.overlays.paletteHints = palette.ContextualHints{} | ||
| 251 | } | 267 | } | ||
| @@ -304,7 +320,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||||
| 304 | d.invalidateRender(entry, ac, true, "palette.go") | 320 | d.invalidateRender(entry, ac, true, "palette.go") | ||
| 305 | return | 321 | return | ||
| 306 | } | 322 | } | ||
| 307 | - | exec := paletteExec{d: d, sess: sess, attachment: entry, ac: ac, recent: recent, effect: effect} | 323 | + | exec := paletteExec{d: d, sess: sess, attachment: entry, ac: ac, routeSnapshot: routeSnapshot, effect: effect} |
| 308 | if err := exec.JumpRecentSession(rank); err != nil { | 324 | if err := exec.JumpRecentSession(rank); err != nil { | ||
| 309 | if errors.Is(err, errAttachmentTransition) { | 325 | if errors.Is(err, errAttachmentTransition) { | ||
| 310 | return | 326 | return | ||
| @@ -371,7 +387,7 @@ func paletteArgs(query string, cmd command.Command) []string { | |||||
| 371 | func (ac *attachedClient) clearPaletteLocked() { | 387 | func (ac *attachedClient) clearPaletteLocked() { | ||
| 372 | ac.overlays.paletteGeneration++ | 388 | ac.overlays.paletteGeneration++ | ||
| 373 | ac.overlays.palette = nil | 389 | ac.overlays.palette = nil | ||
| 374 | - | ac.overlays.paletteRecent = nil | 390 | + | ac.overlays.paletteRouteSnapshot = ports.RecentRouteSnapshot{} |
| 375 | ac.overlays.paletteHints = palette.ContextualHints{} | 391 | ac.overlays.paletteHints = palette.ContextualHints{} | ||
| 376 | ac.overlays.paletteFeedback = "" | 392 | ac.overlays.paletteFeedback = "" | ||
| 377 | ac.overlays.palettePending = nil | 393 | ac.overlays.palettePending = nil | ||
| @@ -401,7 +417,7 @@ type paletteExec struct { | |||||
| 401 | sess *session | 417 | sess *session | ||
| 402 | attachment *session | 418 | attachment *session | ||
| 403 | ac *attachedClient | 419 | ac *attachedClient | ||
| 404 | - | recent []recentSession | 420 | + | routeSnapshot ports.RecentRouteSnapshot |
| 405 | actions daemonActionRunner | 421 | actions daemonActionRunner | ||
| 406 | effect *attachmentEffectTicket | 422 | effect *attachmentEffectTicket | ||
| 407 | redrawClosedPalette bool | 423 | redrawClosedPalette bool | ||
| @@ -634,36 +650,25 @@ func (e paletteExec) YankLastNotification() error { | |||||
| 634 | } | 650 | } | ||
| 635 | 651 | ||||
| 636 | func (e paletteExec) JumpRecentSession(rank int) error { | 652 | func (e paletteExec) JumpRecentSession(rank int) error { | ||
| 637 | - | if rank < 1 || rank > len(e.recent) { | 653 | + | if e.routeSnapshot.Generation == 0 || rank < 1 || rank > len(e.routeSnapshot.Entries) { |
| 638 | return command.ErrInvalidArguments | 654 | return command.ErrInvalidArguments | ||
| 639 | } | 655 | } | ||
| 640 | - | target := e.recent[rank-1] | 656 | + | entry := e.routeSnapshot.Entries[rank-1] |
| 641 | - | if e.d.sessionByID(target.id) == nil { | 657 | + | action := ports.RouteNavigationAction{ |
| 642 | - | return command.ErrInvalidArguments | 658 | + | SnapshotGeneration: e.routeSnapshot.Generation, |
| 659 | + | Key: entry.Key, | |||
| 660 | + | Generation: entry.Generation, | |||
| 643 | } | 661 | } | ||
| 644 | if e.d.beforeRecentSessionHandoff != nil { | 662 | if e.d.beforeRecentSessionHandoff != nil { | ||
| 645 | e.d.beforeRecentSessionHandoff() | 663 | e.d.beforeRecentSessionHandoff() | ||
| 646 | } | 664 | } | ||
| 647 | - | var err error | |||
| 648 | if e.effect != nil { | 665 | if e.effect != nil { | ||
| 649 | - | err = e.d.switchToTargetForAttachment(e.effect.connectionToken(), picker.Target{Session: target.id, TabIndex: -1}, sessionHandoffGuard{}, "palette-recent-session") | 666 | + | return e.d.sendRecentRouteNavigationActionForAttachment(e.effect.connectionToken(), action) |
| 650 | - | } else { | |||
| 651 | - | sess := e.attachment | |||
| 652 | - | if sess == nil { | |||
| 653 | - | sess = e.sess | |||
| 654 | - | } | |||
| 655 | - | if sess == nil { | |||
| 656 | - | return errAttachmentTransition | |||
| 657 | - | } | |||
| 658 | - | err = e.d.switchToTarget(sess, e.ac, picker.Target{Session: target.id, TabIndex: -1}) | |||
| 659 | } | 667 | } | ||
| 660 | - | if e.effect != nil && errors.Is(err, errAttachmentTransition) { | 668 | + | if e.ac == nil || e.attachment == nil { |
| 661 | - | return err | 669 | + | return errAttachmentTransition |
| 662 | } | 670 | } | ||
| 663 | - | if err != nil { | 671 | + | return e.d.sendRecentRouteNavigationActionForAttachment(e.attachment.attachmentToken(e.ac, e.ac.transport()), action) |
| 664 | - | return command.ErrInvalidArguments | |||
| 665 | - | } | |||
| 666 | - | return nil | |||
| 667 | } | 672 | } | ||
| 668 | 673 | ||||
| 669 | func composePaletteClientFrame(model *palette.Model, base renderer.Frame, cfg domain.PaletteConfig, guidance string, styles ...themeui.Styles) (renderer.Frame, []renderer.Damage) { | 674 | func composePaletteClientFrame(model *palette.Model, base renderer.Frame, cfg domain.PaletteConfig, guidance string, styles ...themeui.Styles) (renderer.Frame, []renderer.Damage) { | ||
M internal/usecase/daemon/palette_test.go +71 -101
| @@ -18,6 +18,31 @@ import ( | |||
| 18 | 18 | "github.com/bnema/vev/pkg/renderer" | |
| 19 | 19 | ) | |
| 20 | 20 | ||
| 21 | + | func testRecentRouteSnapshot() ports.RecentRouteSnapshot { | |
| 22 | + | return ports.RecentRouteSnapshot{ | |
| 23 | + | Generation: 1, | |
| 24 | + | Active: ports.RouteRef{Key: 1, Generation: 1}, | |
| 25 | + | Entries: []ports.RecentRouteEntry{ | |
| 26 | + | {Key: 2, Generation: 1, Name: "recent", Kind: ports.RouteKindLocal}, | |
| 27 | + | {Key: 3, Generation: 1, Name: "older", Kind: ports.RouteKindLocal}, | |
| 28 | + | }, | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func beginRecentRoutePaletteEffect(t *testing.T, d *Daemon, sess *session, ac *attachedClient) attachmentConnectionToken { | |
| 33 | + | t.Helper() | |
| 34 | + | transport := ac.transport() | |
| 35 | + | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 36 | + | token := sess.attachmentToken(ac, transport) | |
| 37 | + | token.lease = rc.attachmentLease(ac) | |
| 38 | + | ac.publishAttachmentCapability(token) | |
| 39 | + | effect, admitted := ac.beginAttachmentEffect(token) | |
| 40 | + | require.True(t, admitted) | |
| 41 | + | t.Cleanup(effect.End) | |
| 42 | + | token.effect = effect | |
| 43 | + | return token | |
| 44 | + | } | |
| 45 | + | ||
| 21 | 46 | func TestCaptureOverlayLayersPreservesPaletteDescriptionSurfaceAcrossFallbacks(t *testing.T) { | |
| 22 | 47 | paletteColors := [16]renderer.RGB{} | |
| 23 | 48 | paletteColors[2] = renderer.RGB{R: 10, G: 230, B: 120} | |
| @@ -220,25 +245,6 @@ func TestPaletteEntryPublishesEligibleNamedSessionResults(t *testing.T) { | |||
| 220 | 245 | }, got) | |
| 221 | 246 | } | |
| 222 | 247 | ||
| 223 | - | func TestPaletteRecentSessionsExcludeEphemeralSessions(t *testing.T) { | |
| 224 | - | p, release := newBlockingPTY(t) | |
| 225 | - | defer release() | |
| 226 | - | d, current, ac, _ := newManualSessionWithPTYs(t, p) | |
| 227 | - | named := &session{sessionCore: sessionCore{id: "named", name: "named"}, tabs: []*tab{{}}} | |
| 228 | - | named.mruAt.Store(1) | |
| 229 | - | ephemeral := &session{sessionCore: sessionCore{id: "ephemeral", name: "1", ephemeral: true}, tabs: []*tab{{}}} | |
| 230 | - | ephemeral.mruAt.Store(2) | |
| 231 | - | d.sessions[named.id] = named | |
| 232 | - | d.sessions[ephemeral.id] = ephemeral | |
| 233 | - | ||
| 234 | - | d.enterPalette(current, ac) | |
| 235 | - | ac.overlays.paletteMu.Lock() | |
| 236 | - | defer ac.overlays.paletteMu.Unlock() | |
| 237 | - | ||
| 238 | - | require.Len(t, ac.overlays.paletteRecent, 1) | |
| 239 | - | require.Equal(t, named.id, ac.overlays.paletteRecent[0].id) | |
| 240 | - | } | |
| 241 | - | ||
| 242 | 248 | func TestPaletteSessionFailureFeedbackClearsOnQueryChange(t *testing.T) { | |
| 243 | 249 | p, release := newBlockingPTY(t) | |
| 244 | 250 | defer release() | |
| @@ -359,6 +365,7 @@ func TestPaletteJRSActivatesOnlyExactContextualHint(t *testing.T) { | |||
| 359 | 365 | p, release := newBlockingPTY(t) | |
| 360 | 366 | defer release() | |
| 361 | 367 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 368 | + | ac.setRouteSnapshot(ports.RecentRouteSnapshot{Generation: 1, Active: ports.RouteRef{Key: 1, Generation: 1}}) | |
| 362 | 369 | ||
| 363 | 370 | d.handleInput(sess, ac, []byte("\x1b ")) | |
| 364 | 371 | awaitFrame(t, sends, ports.MsgOutput) | |
| @@ -380,31 +387,35 @@ func TestPaletteJRSActivatesOnlyExactContextualHint(t *testing.T) { | |||
| 380 | 387 | func TestPaletteJRSUsesEffectiveOverrideOnly(t *testing.T) { | |
| 381 | 388 | d, current, ac, sends, releases := newRecentNavigationTestSessions(t) | |
| 382 | 389 | defer releaseAll(releases) | |
| 390 | + | ac.setRouteSnapshot(testRecentRouteSnapshot()) | |
| 391 | + | token := beginRecentRoutePaletteEffect(t, d, current, ac) | |
| 383 | 392 | d.ApplyConfig(domain.Config{Codes: map[string]string{"jump-recent-session": "RJS"}}) | |
| 384 | 393 | ||
| 385 | - | d.handleInput(current, ac, []byte("\x1b ")) | |
| 394 | + | d.handleInputForAttachment(token, []byte("\x1b ")) | |
| 386 | 395 | awaitFrame(t, sends, ports.MsgOutput) | |
| 387 | - | d.handleInput(current, ac, []byte("RJS")) | |
| 396 | + | d.handleInputForAttachment(token, []byte("RJS")) | |
| 388 | 397 | awaitFrame(t, sends, ports.MsgOutput) | |
| 389 | 398 | ac.overlays.paletteMu.Lock() | |
| 390 | 399 | require.Equal(t, command.ContextHintRecentSessions, ac.overlays.paletteHints.Kind) | |
| 391 | 400 | ac.overlays.paletteMu.Unlock() | |
| 392 | - | d.handleInput(current, ac, []byte(" 1\r")) | |
| 401 | + | d.handleInputForAttachment(token, []byte(" 1\r")) | |
| 393 | 402 | ||
| 394 | - | require.Same(t, d.sessions[domain.SessionID("recent")], ac.currentSession()) | |
| 403 | + | actionFrame := awaitFrame(t, sends, ports.MsgNavigateRecentRoute) | |
| 404 | + | action, err := ports.UnmarshalRouteNavigationAction(actionFrame.Payload) | |
| 405 | + | require.NoError(t, err) | |
| 406 | + | require.Equal(t, ports.RouteNavigationAction{SnapshotGeneration: 1, Key: 2, Generation: 1}, action) | |
| 407 | + | require.Same(t, current, ac.currentSession()) | |
| 395 | 408 | require.False(t, ac.overlays.paletteActive()) | |
| 396 | - | awaitFrame(t, sends, ports.MsgOutput) | |
| 397 | - | awaitFrame(t, sends, ports.MsgOutput) | |
| 398 | 409 | ||
| 399 | - | d.handleInput(ac.currentSession(), ac, []byte("\x1b ")) | |
| 410 | + | d.handleInputForAttachment(token, []byte("\x1b ")) | |
| 400 | 411 | awaitFrame(t, sends, ports.MsgOutput) | |
| 401 | - | d.handleInput(ac.currentSession(), ac, []byte("JRS")) | |
| 412 | + | d.handleInputForAttachment(token, []byte("JRS")) | |
| 402 | 413 | awaitFrame(t, sends, ports.MsgOutput) | |
| 403 | 414 | ac.overlays.paletteMu.Lock() | |
| 404 | 415 | require.Equal(t, command.ContextHintNone, ac.overlays.paletteHints.Kind) | |
| 405 | 416 | ac.overlays.paletteMu.Unlock() | |
| 406 | - | require.Same(t, d.sessions[domain.SessionID("recent")], ac.currentSession()) | |
| 407 | - | d.handleInput(ac.currentSession(), ac, []byte("\r")) | |
| 417 | + | require.Same(t, current, ac.currentSession()) | |
| 418 | + | d.handleInputForAttachment(token, []byte("\r")) | |
| 408 | 419 | require.True(t, ac.overlays.paletteActive(), "literal JRS must not execute an overridden jump command") | |
| 409 | 420 | } | |
| 410 | 421 | ||
| @@ -425,62 +436,20 @@ func TestPaletteFuzzySelectedStaticCommandExecutes(t *testing.T) { | |||
| 425 | 436 | func TestPaletteJRSUsesCapturedRankAfterMRUChanges(t *testing.T) { | |
| 426 | 437 | d, current, ac, sends, releases := newRecentNavigationTestSessions(t) | |
| 427 | 438 | defer releaseAll(releases) | |
| 428 | - | captured := mustLocalSession(t, d.sessions[domain.SessionID("recent")]) | |
| 429 | - | d.handleInput(current, ac, []byte("\x1b ")) | |
| 439 | + | ac.setRouteSnapshot(testRecentRouteSnapshot()) | |
| 440 | + | token := beginRecentRoutePaletteEffect(t, d, current, ac) | |
| 441 | + | d.handleInputForAttachment(token, []byte("\x1b ")) | |
| 430 | 442 | awaitFrame(t, sends, ports.MsgOutput) | |
| 431 | 443 | // Reordering live MRU after opening must not shift rank 1 from its capture. | |
| 432 | 444 | d.sessions[domain.SessionID("older")].core().mruAt.Store(100) | |
| 433 | - | d.handleInput(current, ac, []byte("JRS 1\r")) | |
| 434 | - | ||
| 435 | - | require.Same(t, captured, ac.currentSession()) | |
| 436 | - | require.False(t, ac.overlays.paletteActive()) | |
| 437 | - | awaitFrame(t, sends, ports.MsgOutput) | |
| 438 | - | awaitFrame(t, sends, ports.MsgOutput) | |
| 439 | - | } | |
| 440 | - | ||
| 441 | - | func TestPaletteJRSThenBSKReversesJump(t *testing.T) { | |
| 442 | - | d, current, ac, sends, releases := newRecentNavigationTestSessions(t) | |
| 443 | - | defer releaseAll(releases) | |
| 445 | + | d.handleInputForAttachment(token, []byte("JRS 1\r")) | |
| 444 | 446 | ||
| 445 | - | d.handleInput(current, ac, []byte("\x1b ")) | |
| 446 | - | awaitFrame(t, sends, ports.MsgOutput) | |
| 447 | - | d.handleInput(current, ac, []byte("JRS 1\r")) | |
| 448 | - | require.Same(t, d.sessions[domain.SessionID("recent")], ac.currentSession()) | |
| 449 | - | awaitFrame(t, sends, ports.MsgOutput) | |
| 450 | - | awaitFrame(t, sends, ports.MsgOutput) | |
| 451 | - | ||
| 452 | - | runPaletteCommand(t, d, ac.currentSession(), ac, "BSK") | |
| 447 | + | actionFrame := awaitFrame(t, sends, ports.MsgNavigateRecentRoute) | |
| 448 | + | action, err := ports.UnmarshalRouteNavigationAction(actionFrame.Payload) | |
| 449 | + | require.NoError(t, err) | |
| 450 | + | require.Equal(t, ports.RouteNavigationAction{SnapshotGeneration: 1, Key: 2, Generation: 1}, action) | |
| 453 | 451 | require.Same(t, current, ac.currentSession()) | |
| 454 | - | } | |
| 455 | - | ||
| 456 | - | func TestPaletteFailedRoleHandoffClosesExecutedInteraction(t *testing.T) { | |
| 457 | - | d, current, ac, _, releases := newRecentNavigationTestSessions(t) | |
| 458 | - | defer releaseAll(releases) | |
| 459 | - | ||
| 460 | - | // Establish a valid back-session target, then remove that target immediately | |
| 461 | - | // after the command releases its role admission. | |
| 462 | - | target := d.sessions[domain.SessionID("recent")] | |
| 463 | - | ac.previousSession.Set(target.id) | |
| 464 | - | invalidations := installPaletteInvalidationObserver(current) | |
| 465 | - | d.enterPalette(current, ac) | |
| 466 | - | d.handlePaletteInput(ac, []byte("BSK")) | |
| 467 | - | d.afterActionAttachmentEffectEnded = func(action string) { | |
| 468 | - | if action == "back-session" { | |
| 469 | - | d.mu.Lock() | |
| 470 | - | delete(d.sessions, target.core().id) | |
| 471 | - | d.mu.Unlock() | |
| 472 | - | } | |
| 473 | - | } | |
| 474 | - | ||
| 475 | - | token := current.attachmentToken(ac, ac.transport()) | |
| 476 | - | effect, admitted := ac.beginAttachmentEffect(token) | |
| 477 | - | require.True(t, admitted) | |
| 478 | - | d.handlePaletteInput(ac, []byte("\r"), effect) | |
| 479 | - | ||
| 480 | - | require.False(t, ac.overlays.paletteActive(), "a failed executed handoff must not leave a stale palette") | |
| 481 | - | require.Same(t, current, ac.currentSession(), "failed handoff must preserve the source attachment") | |
| 482 | - | invalidation := awaitTestValue(t, invalidations, "failed palette handoff did not invalidate rendering") | |
| 483 | - | require.True(t, invalidation.reset) | |
| 452 | + | require.False(t, ac.overlays.paletteActive()) | |
| 484 | 453 | } | |
| 485 | 454 | ||
| 486 | 455 | func TestPaletteDeniedPostHandoffAttachmentEffectClosesAndInvalidates(t *testing.T) { | |
| @@ -505,12 +474,15 @@ func TestPaletteDeniedPostHandoffAttachmentEffectClosesAndInvalidates(t *testing | |||
| 505 | 474 | require.True(t, invalidation.reset) | |
| 506 | 475 | } | |
| 507 | 476 | ||
| 508 | - | func TestPaletteJRSDisplacedTargetKeepsInteractionOpen(t *testing.T) { | |
| 477 | + | func TestPaletteJRSDoesNotRevalidateTargetInDaemon(t *testing.T) { | |
| 509 | 478 | p, release := newBlockingPTY(t) | |
| 510 | 479 | defer release() | |
| 511 | 480 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 512 | - | target := &session{sessionCore: sessionCore{id: "captured", name: "captured"}} | |
| 513 | - | d.sessions[target.id] = target | |
| 481 | + | ac.setRouteSnapshot(ports.RecentRouteSnapshot{ | |
| 482 | + | Generation: 7, | |
| 483 | + | Active: ports.RouteRef{Key: 1, Generation: 1}, | |
| 484 | + | Entries: []ports.RecentRouteEntry{{Key: 9, Generation: 4, Name: "captured", Kind: ports.RouteKindLocal}}, | |
| 485 | + | }) | |
| 514 | 486 | ||
| 515 | 487 | validated := make(chan struct{}) | |
| 516 | 488 | releaseHandoff := make(chan struct{}) | |
| @@ -518,38 +490,36 @@ func TestPaletteJRSDisplacedTargetKeepsInteractionOpen(t *testing.T) { | |||
| 518 | 490 | close(validated) | |
| 519 | 491 | <-releaseHandoff | |
| 520 | 492 | } | |
| 521 | - | d.handleInput(sess, ac, []byte("\x1b ")) | |
| 493 | + | token := beginRecentRoutePaletteEffect(t, d, sess, ac) | |
| 494 | + | d.handleInputForAttachment(token, []byte("\x1b ")) | |
| 522 | 495 | awaitFrame(t, sends, ports.MsgOutput) | |
| 523 | 496 | inputHandled := make(chan struct{}) | |
| 524 | 497 | go func() { | |
| 525 | - | d.handleInput(sess, ac, []byte("JRS 1\r")) | |
| 498 | + | d.handleInputForAttachment(token, []byte("JRS 1\r")) | |
| 526 | 499 | close(inputHandled) | |
| 527 | 500 | }() | |
| 528 | - | awaitTestCompletion(t, validated, "JRS did not validate its captured target") | |
| 529 | - | d.mu.Lock() | |
| 530 | - | delete(d.sessions, target.core().id) | |
| 531 | - | d.mu.Unlock() | |
| 501 | + | awaitTestCompletion(t, validated, "JRS did not publish the captured route action") | |
| 502 | + | // The daemon must not consult or revalidate the target lifecycle. The | |
| 503 | + | // client owns exact-target validation after it receives this action. | |
| 532 | 504 | close(releaseHandoff) | |
| 505 | + | awaitTestCompletion(t, inputHandled, "JRS route-action input did not complete") | |
| 533 | 506 | ||
| 534 | - | // switchToTarget repaints its failed hand-off before handlePaletteInput | |
| 535 | - | // records the generation-safe feedback and schedules the feedback repaint. | |
| 536 | - | // Wait for the input transaction, rather than mistaking that intermediate | |
| 537 | - | // repaint for publication of the final palette state. | |
| 538 | - | awaitTestCompletion(t, inputHandled, "JRS displaced-target input did not complete") | |
| 539 | - | require.True(t, ac.overlays.paletteActive()) | |
| 540 | - | ac.overlays.paletteMu.Lock() | |
| 541 | - | require.Equal(t, "JRS 1", ac.overlays.palette.Query()) | |
| 542 | - | require.Equal(t, "requested recent session is unavailable", ac.overlays.paletteFeedback) | |
| 543 | - | ac.overlays.paletteMu.Unlock() | |
| 507 | + | actionFrame := awaitFrame(t, sends, ports.MsgNavigateRecentRoute) | |
| 508 | + | action, err := ports.UnmarshalRouteNavigationAction(actionFrame.Payload) | |
| 509 | + | require.NoError(t, err) | |
| 510 | + | require.Equal(t, ports.RouteNavigationAction{SnapshotGeneration: 7, Key: 9, Generation: 4}, action) | |
| 511 | + | require.False(t, ac.overlays.paletteActive()) | |
| 544 | 512 | } | |
| 545 | 513 | ||
| 546 | 514 | func TestPaletteJRSOutOfRangeKeepsPaletteOpenWithoutClamping(t *testing.T) { | |
| 547 | 515 | d, current, ac, sends, releases := newRecentNavigationTestSessions(t) | |
| 548 | 516 | defer releaseAll(releases) | |
| 517 | + | ac.setRouteSnapshot(testRecentRouteSnapshot()) | |
| 518 | + | token := beginRecentRoutePaletteEffect(t, d, current, ac) | |
| 549 | 519 | ||
| 550 | - | d.handleInput(current, ac, []byte("\x1b ")) | |
| 520 | + | d.handleInputForAttachment(token, []byte("\x1b ")) | |
| 551 | 521 | awaitFrame(t, sends, ports.MsgOutput) | |
| 552 | - | d.handleInput(current, ac, []byte("JRS 3\r")) | |
| 522 | + | d.handleInputForAttachment(token, []byte("JRS 3\r")) | |
| 553 | 523 | ||
| 554 | 524 | require.Same(t, current, ac.currentSession()) | |
| 555 | 525 | require.True(t, ac.overlays.paletteActive()) | |
| @@ -18,6 +18,31 @@ import ( | |||||
| 18 | "github.com/bnema/vev/pkg/renderer" | 18 | "github.com/bnema/vev/pkg/renderer" | ||
| 19 | ) | 19 | ) | ||
| 20 | 20 | ||||
| 21 | + | func testRecentRouteSnapshot() ports.RecentRouteSnapshot { | |||
| 22 | + | return ports.RecentRouteSnapshot{ | |||
| 23 | + | Generation: 1, | |||
| 24 | + | Active: ports.RouteRef{Key: 1, Generation: 1}, | |||
| 25 | + | Entries: []ports.RecentRouteEntry{ | |||
| 26 | + | {Key: 2, Generation: 1, Name: "recent", Kind: ports.RouteKindLocal}, | |||
| 27 | + | {Key: 3, Generation: 1, Name: "older", Kind: ports.RouteKindLocal}, | |||
| 28 | + | }, | |||
| 29 | + | } | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | func beginRecentRoutePaletteEffect(t *testing.T, d *Daemon, sess *session, ac *attachedClient) attachmentConnectionToken { | |||
| 33 | + | t.Helper() | |||
| 34 | + | transport := ac.transport() | |||
| 35 | + | rc := d.attachCoordinator(sess, nil, ac, true) | |||
| 36 | + | token := sess.attachmentToken(ac, transport) | |||
| 37 | + | token.lease = rc.attachmentLease(ac) | |||
| 38 | + | ac.publishAttachmentCapability(token) | |||
| 39 | + | effect, admitted := ac.beginAttachmentEffect(token) | |||
| 40 | + | require.True(t, admitted) | |||
| 41 | + | t.Cleanup(effect.End) | |||
| 42 | + | token.effect = effect | |||
| 43 | + | return token | |||
| 44 | + | } | |||
| 45 | + | ||||
| 21 | func TestCaptureOverlayLayersPreservesPaletteDescriptionSurfaceAcrossFallbacks(t *testing.T) { | 46 | func TestCaptureOverlayLayersPreservesPaletteDescriptionSurfaceAcrossFallbacks(t *testing.T) { | ||
| 22 | paletteColors := [16]renderer.RGB{} | 47 | paletteColors := [16]renderer.RGB{} | ||
| 23 | paletteColors[2] = renderer.RGB{R: 10, G: 230, B: 120} | 48 | paletteColors[2] = renderer.RGB{R: 10, G: 230, B: 120} | ||
| @@ -220,25 +245,6 @@ func TestPaletteEntryPublishesEligibleNamedSessionResults(t *testing.T) { | |||||
| 220 | }, got) | 245 | }, got) | ||
| 221 | } | 246 | } | ||
| 222 | 247 | ||||
| 223 | - | func TestPaletteRecentSessionsExcludeEphemeralSessions(t *testing.T) { | |||
| 224 | - | p, release := newBlockingPTY(t) | |||
| 225 | - | defer release() | |||
| 226 | - | d, current, ac, _ := newManualSessionWithPTYs(t, p) | |||
| 227 | - | named := &session{sessionCore: sessionCore{id: "named", name: "named"}, tabs: []*tab{{}}} | |||
| 228 | - | named.mruAt.Store(1) | |||
| 229 | - | ephemeral := &session{sessionCore: sessionCore{id: "ephemeral", name: "1", ephemeral: true}, tabs: []*tab{{}}} | |||
| 230 | - | ephemeral.mruAt.Store(2) | |||
| 231 | - | d.sessions[named.id] = named | |||
| 232 | - | d.sessions[ephemeral.id] = ephemeral | |||
| 233 | - | ||||
| 234 | - | d.enterPalette(current, ac) | |||
| 235 | - | ac.overlays.paletteMu.Lock() | |||
| 236 | - | defer ac.overlays.paletteMu.Unlock() | |||
| 237 | - | ||||
| 238 | - | require.Len(t, ac.overlays.paletteRecent, 1) | |||
| 239 | - | require.Equal(t, named.id, ac.overlays.paletteRecent[0].id) | |||
| 240 | - | } | |||
| 241 | - | ||||
| 242 | func TestPaletteSessionFailureFeedbackClearsOnQueryChange(t *testing.T) { | 248 | func TestPaletteSessionFailureFeedbackClearsOnQueryChange(t *testing.T) { | ||
| 243 | p, release := newBlockingPTY(t) | 249 | p, release := newBlockingPTY(t) | ||
| 244 | defer release() | 250 | defer release() | ||
| @@ -359,6 +365,7 @@ func TestPaletteJRSActivatesOnlyExactContextualHint(t *testing.T) { | |||||
| 359 | p, release := newBlockingPTY(t) | 365 | p, release := newBlockingPTY(t) | ||
| 360 | defer release() | 366 | defer release() | ||
| 361 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 367 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 368 | + | ac.setRouteSnapshot(ports.RecentRouteSnapshot{Generation: 1, Active: ports.RouteRef{Key: 1, Generation: 1}}) | |||
| 362 | 369 | ||||
| 363 | d.handleInput(sess, ac, []byte("\x1b ")) | 370 | d.handleInput(sess, ac, []byte("\x1b ")) | ||
| 364 | awaitFrame(t, sends, ports.MsgOutput) | 371 | awaitFrame(t, sends, ports.MsgOutput) | ||
| @@ -380,31 +387,35 @@ func TestPaletteJRSActivatesOnlyExactContextualHint(t *testing.T) { | |||||
| 380 | func TestPaletteJRSUsesEffectiveOverrideOnly(t *testing.T) { | 387 | func TestPaletteJRSUsesEffectiveOverrideOnly(t *testing.T) { | ||
| 381 | d, current, ac, sends, releases := newRecentNavigationTestSessions(t) | 388 | d, current, ac, sends, releases := newRecentNavigationTestSessions(t) | ||
| 382 | defer releaseAll(releases) | 389 | defer releaseAll(releases) | ||
| 390 | + | ac.setRouteSnapshot(testRecentRouteSnapshot()) | |||
| 391 | + | token := beginRecentRoutePaletteEffect(t, d, current, ac) | |||
| 383 | d.ApplyConfig(domain.Config{Codes: map[string]string{"jump-recent-session": "RJS"}}) | 392 | d.ApplyConfig(domain.Config{Codes: map[string]string{"jump-recent-session": "RJS"}}) | ||
| 384 | 393 | ||||
| 385 | - | d.handleInput(current, ac, []byte("\x1b ")) | 394 | + | d.handleInputForAttachment(token, []byte("\x1b ")) |
| 386 | awaitFrame(t, sends, ports.MsgOutput) | 395 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 387 | - | d.handleInput(current, ac, []byte("RJS")) | 396 | + | d.handleInputForAttachment(token, []byte("RJS")) |
| 388 | awaitFrame(t, sends, ports.MsgOutput) | 397 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 389 | ac.overlays.paletteMu.Lock() | 398 | ac.overlays.paletteMu.Lock() | ||
| 390 | require.Equal(t, command.ContextHintRecentSessions, ac.overlays.paletteHints.Kind) | 399 | require.Equal(t, command.ContextHintRecentSessions, ac.overlays.paletteHints.Kind) | ||
| 391 | ac.overlays.paletteMu.Unlock() | 400 | ac.overlays.paletteMu.Unlock() | ||
| 392 | - | d.handleInput(current, ac, []byte(" 1\r")) | 401 | + | d.handleInputForAttachment(token, []byte(" 1\r")) |
| 393 | 402 | ||||
| 394 | - | require.Same(t, d.sessions[domain.SessionID("recent")], ac.currentSession()) | 403 | + | actionFrame := awaitFrame(t, sends, ports.MsgNavigateRecentRoute) |
| 404 | + | action, err := ports.UnmarshalRouteNavigationAction(actionFrame.Payload) | |||
| 405 | + | require.NoError(t, err) | |||
| 406 | + | require.Equal(t, ports.RouteNavigationAction{SnapshotGeneration: 1, Key: 2, Generation: 1}, action) | |||
| 407 | + | require.Same(t, current, ac.currentSession()) | |||
| 395 | require.False(t, ac.overlays.paletteActive()) | 408 | require.False(t, ac.overlays.paletteActive()) | ||
| 396 | - | awaitFrame(t, sends, ports.MsgOutput) | |||
| 397 | - | awaitFrame(t, sends, ports.MsgOutput) | |||
| 398 | 409 | ||||
| 399 | - | d.handleInput(ac.currentSession(), ac, []byte("\x1b ")) | 410 | + | d.handleInputForAttachment(token, []byte("\x1b ")) |
| 400 | awaitFrame(t, sends, ports.MsgOutput) | 411 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 401 | - | d.handleInput(ac.currentSession(), ac, []byte("JRS")) | 412 | + | d.handleInputForAttachment(token, []byte("JRS")) |
| 402 | awaitFrame(t, sends, ports.MsgOutput) | 413 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 403 | ac.overlays.paletteMu.Lock() | 414 | ac.overlays.paletteMu.Lock() | ||
| 404 | require.Equal(t, command.ContextHintNone, ac.overlays.paletteHints.Kind) | 415 | require.Equal(t, command.ContextHintNone, ac.overlays.paletteHints.Kind) | ||
| 405 | ac.overlays.paletteMu.Unlock() | 416 | ac.overlays.paletteMu.Unlock() | ||
| 406 | - | require.Same(t, d.sessions[domain.SessionID("recent")], ac.currentSession()) | 417 | + | require.Same(t, current, ac.currentSession()) |
| 407 | - | d.handleInput(ac.currentSession(), ac, []byte("\r")) | 418 | + | d.handleInputForAttachment(token, []byte("\r")) |
| 408 | require.True(t, ac.overlays.paletteActive(), "literal JRS must not execute an overridden jump command") | 419 | require.True(t, ac.overlays.paletteActive(), "literal JRS must not execute an overridden jump command") | ||
| 409 | } | 420 | } | ||
| 410 | 421 | ||||
| @@ -425,62 +436,20 @@ func TestPaletteFuzzySelectedStaticCommandExecutes(t *testing.T) { | |||||
| 425 | func TestPaletteJRSUsesCapturedRankAfterMRUChanges(t *testing.T) { | 436 | func TestPaletteJRSUsesCapturedRankAfterMRUChanges(t *testing.T) { | ||
| 426 | d, current, ac, sends, releases := newRecentNavigationTestSessions(t) | 437 | d, current, ac, sends, releases := newRecentNavigationTestSessions(t) | ||
| 427 | defer releaseAll(releases) | 438 | defer releaseAll(releases) | ||
| 428 | - | captured := mustLocalSession(t, d.sessions[domain.SessionID("recent")]) | 439 | + | ac.setRouteSnapshot(testRecentRouteSnapshot()) |
| 429 | - | d.handleInput(current, ac, []byte("\x1b ")) | 440 | + | token := beginRecentRoutePaletteEffect(t, d, current, ac) |
| 441 | + | d.handleInputForAttachment(token, []byte("\x1b ")) | |||
| 430 | awaitFrame(t, sends, ports.MsgOutput) | 442 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 431 | // Reordering live MRU after opening must not shift rank 1 from its capture. | 443 | // Reordering live MRU after opening must not shift rank 1 from its capture. | ||
| 432 | d.sessions[domain.SessionID("older")].core().mruAt.Store(100) | 444 | d.sessions[domain.SessionID("older")].core().mruAt.Store(100) | ||
| 433 | - | d.handleInput(current, ac, []byte("JRS 1\r")) | 445 | + | d.handleInputForAttachment(token, []byte("JRS 1\r")) |
| 434 | - | ||||
| 435 | - | require.Same(t, captured, ac.currentSession()) | |||
| 436 | - | require.False(t, ac.overlays.paletteActive()) | |||
| 437 | - | awaitFrame(t, sends, ports.MsgOutput) | |||
| 438 | - | awaitFrame(t, sends, ports.MsgOutput) | |||
| 439 | - | } | |||
| 440 | - | ||||
| 441 | - | func TestPaletteJRSThenBSKReversesJump(t *testing.T) { | |||
| 442 | - | d, current, ac, sends, releases := newRecentNavigationTestSessions(t) | |||
| 443 | - | defer releaseAll(releases) | |||
| 444 | 446 | ||||
| 445 | - | d.handleInput(current, ac, []byte("\x1b ")) | 447 | + | actionFrame := awaitFrame(t, sends, ports.MsgNavigateRecentRoute) |
| 446 | - | awaitFrame(t, sends, ports.MsgOutput) | 448 | + | action, err := ports.UnmarshalRouteNavigationAction(actionFrame.Payload) |
| 447 | - | d.handleInput(current, ac, []byte("JRS 1\r")) | 449 | + | require.NoError(t, err) |
| 448 | - | require.Same(t, d.sessions[domain.SessionID("recent")], ac.currentSession()) | 450 | + | require.Equal(t, ports.RouteNavigationAction{SnapshotGeneration: 1, Key: 2, Generation: 1}, action) |
| 449 | - | awaitFrame(t, sends, ports.MsgOutput) | |||
| 450 | - | awaitFrame(t, sends, ports.MsgOutput) | |||
| 451 | - | ||||
| 452 | - | runPaletteCommand(t, d, ac.currentSession(), ac, "BSK") | |||
| 453 | require.Same(t, current, ac.currentSession()) | 451 | require.Same(t, current, ac.currentSession()) | ||
| 454 | - | } | 452 | + | require.False(t, ac.overlays.paletteActive()) |
| 455 | - | ||||
| 456 | - | func TestPaletteFailedRoleHandoffClosesExecutedInteraction(t *testing.T) { | |||
| 457 | - | d, current, ac, _, releases := newRecentNavigationTestSessions(t) | |||
| 458 | - | defer releaseAll(releases) | |||
| 459 | - | ||||
| 460 | - | // Establish a valid back-session target, then remove that target immediately | |||
| 461 | - | // after the command releases its role admission. | |||
| 462 | - | target := d.sessions[domain.SessionID("recent")] | |||
| 463 | - | ac.previousSession.Set(target.id) | |||
| 464 | - | invalidations := installPaletteInvalidationObserver(current) | |||
| 465 | - | d.enterPalette(current, ac) | |||
| 466 | - | d.handlePaletteInput(ac, []byte("BSK")) | |||
| 467 | - | d.afterActionAttachmentEffectEnded = func(action string) { | |||
| 468 | - | if action == "back-session" { | |||
| 469 | - | d.mu.Lock() | |||
| 470 | - | delete(d.sessions, target.core().id) | |||
| 471 | - | d.mu.Unlock() | |||
| 472 | - | } | |||
| 473 | - | } | |||
| 474 | - | ||||
| 475 | - | token := current.attachmentToken(ac, ac.transport()) | |||
| 476 | - | effect, admitted := ac.beginAttachmentEffect(token) | |||
| 477 | - | require.True(t, admitted) | |||
| 478 | - | d.handlePaletteInput(ac, []byte("\r"), effect) | |||
| 479 | - | ||||
| 480 | - | require.False(t, ac.overlays.paletteActive(), "a failed executed handoff must not leave a stale palette") | |||
| 481 | - | require.Same(t, current, ac.currentSession(), "failed handoff must preserve the source attachment") | |||
| 482 | - | invalidation := awaitTestValue(t, invalidations, "failed palette handoff did not invalidate rendering") | |||
| 483 | - | require.True(t, invalidation.reset) | |||
| 484 | } | 453 | } | ||
| 485 | 454 | ||||
| 486 | func TestPaletteDeniedPostHandoffAttachmentEffectClosesAndInvalidates(t *testing.T) { | 455 | func TestPaletteDeniedPostHandoffAttachmentEffectClosesAndInvalidates(t *testing.T) { | ||
| @@ -505,12 +474,15 @@ func TestPaletteDeniedPostHandoffAttachmentEffectClosesAndInvalidates(t *testing | |||||
| 505 | require.True(t, invalidation.reset) | 474 | require.True(t, invalidation.reset) | ||
| 506 | } | 475 | } | ||
| 507 | 476 | ||||
| 508 | - | func TestPaletteJRSDisplacedTargetKeepsInteractionOpen(t *testing.T) { | 477 | + | func TestPaletteJRSDoesNotRevalidateTargetInDaemon(t *testing.T) { |
| 509 | p, release := newBlockingPTY(t) | 478 | p, release := newBlockingPTY(t) | ||
| 510 | defer release() | 479 | defer release() | ||
| 511 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 480 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 512 | - | target := &session{sessionCore: sessionCore{id: "captured", name: "captured"}} | 481 | + | ac.setRouteSnapshot(ports.RecentRouteSnapshot{ |
| 513 | - | d.sessions[target.id] = target | 482 | + | Generation: 7, |
| 483 | + | Active: ports.RouteRef{Key: 1, Generation: 1}, | |||
| 484 | + | Entries: []ports.RecentRouteEntry{{Key: 9, Generation: 4, Name: "captured", Kind: ports.RouteKindLocal}}, | |||
| 485 | + | }) | |||
| 514 | 486 | ||||
| 515 | validated := make(chan struct{}) | 487 | validated := make(chan struct{}) | ||
| 516 | releaseHandoff := make(chan struct{}) | 488 | releaseHandoff := make(chan struct{}) | ||
| @@ -518,38 +490,36 @@ func TestPaletteJRSDisplacedTargetKeepsInteractionOpen(t *testing.T) { | |||||
| 518 | close(validated) | 490 | close(validated) | ||
| 519 | <-releaseHandoff | 491 | <-releaseHandoff | ||
| 520 | } | 492 | } | ||
| 521 | - | d.handleInput(sess, ac, []byte("\x1b ")) | 493 | + | token := beginRecentRoutePaletteEffect(t, d, sess, ac) |
| 494 | + | d.handleInputForAttachment(token, []byte("\x1b ")) | |||
| 522 | awaitFrame(t, sends, ports.MsgOutput) | 495 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 523 | inputHandled := make(chan struct{}) | 496 | inputHandled := make(chan struct{}) | ||
| 524 | go func() { | 497 | go func() { | ||
| 525 | - | d.handleInput(sess, ac, []byte("JRS 1\r")) | 498 | + | d.handleInputForAttachment(token, []byte("JRS 1\r")) |
| 526 | close(inputHandled) | 499 | close(inputHandled) | ||
| 527 | }() | 500 | }() | ||
| 528 | - | awaitTestCompletion(t, validated, "JRS did not validate its captured target") | 501 | + | awaitTestCompletion(t, validated, "JRS did not publish the captured route action") |
| 529 | - | d.mu.Lock() | 502 | + | // The daemon must not consult or revalidate the target lifecycle. The |
| 530 | - | delete(d.sessions, target.core().id) | 503 | + | // client owns exact-target validation after it receives this action. |
| 531 | - | d.mu.Unlock() | |||
| 532 | close(releaseHandoff) | 504 | close(releaseHandoff) | ||
| 505 | + | awaitTestCompletion(t, inputHandled, "JRS route-action input did not complete") | |||
| 533 | 506 | ||||
| 534 | - | // switchToTarget repaints its failed hand-off before handlePaletteInput | 507 | + | actionFrame := awaitFrame(t, sends, ports.MsgNavigateRecentRoute) |
| 535 | - | // records the generation-safe feedback and schedules the feedback repaint. | 508 | + | action, err := ports.UnmarshalRouteNavigationAction(actionFrame.Payload) |
| 536 | - | // Wait for the input transaction, rather than mistaking that intermediate | 509 | + | require.NoError(t, err) |
| 537 | - | // repaint for publication of the final palette state. | 510 | + | require.Equal(t, ports.RouteNavigationAction{SnapshotGeneration: 7, Key: 9, Generation: 4}, action) |
| 538 | - | awaitTestCompletion(t, inputHandled, "JRS displaced-target input did not complete") | 511 | + | require.False(t, ac.overlays.paletteActive()) |
| 539 | - | require.True(t, ac.overlays.paletteActive()) | |||
| 540 | - | ac.overlays.paletteMu.Lock() | |||
| 541 | - | require.Equal(t, "JRS 1", ac.overlays.palette.Query()) | |||
| 542 | - | require.Equal(t, "requested recent session is unavailable", ac.overlays.paletteFeedback) | |||
| 543 | - | ac.overlays.paletteMu.Unlock() | |||
| 544 | } | 512 | } | ||
| 545 | 513 | ||||
| 546 | func TestPaletteJRSOutOfRangeKeepsPaletteOpenWithoutClamping(t *testing.T) { | 514 | func TestPaletteJRSOutOfRangeKeepsPaletteOpenWithoutClamping(t *testing.T) { | ||
| 547 | d, current, ac, sends, releases := newRecentNavigationTestSessions(t) | 515 | d, current, ac, sends, releases := newRecentNavigationTestSessions(t) | ||
| 548 | defer releaseAll(releases) | 516 | defer releaseAll(releases) | ||
| 517 | + | ac.setRouteSnapshot(testRecentRouteSnapshot()) | |||
| 518 | + | token := beginRecentRoutePaletteEffect(t, d, current, ac) | |||
| 549 | 519 | ||||
| 550 | - | d.handleInput(current, ac, []byte("\x1b ")) | 520 | + | d.handleInputForAttachment(token, []byte("\x1b ")) |
| 551 | awaitFrame(t, sends, ports.MsgOutput) | 521 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 552 | - | d.handleInput(current, ac, []byte("JRS 3\r")) | 522 | + | d.handleInputForAttachment(token, []byte("JRS 3\r")) |
| 553 | 523 | ||||
| 554 | require.Same(t, current, ac.currentSession()) | 524 | require.Same(t, current, ac.currentSession()) | ||
| 555 | require.True(t, ac.overlays.paletteActive()) | 525 | require.True(t, ac.overlays.paletteActive()) | ||
M internal/usecase/daemon/picker.go +0 -4
| @@ -859,7 +859,6 @@ func (d *Daemon) switchActiveTargetForAttachmentGuarded(token attachmentConnecti | |||
| 859 | 859 | } | |
| 860 | 860 | } | |
| 861 | 861 | d.touchMRU(targetSess) | |
| 862 | - | token.ac.recordPreviousSession(token.sess) | |
| 863 | 862 | d.deferAttachmentTransitionCleanups(transition) | |
| 864 | 863 | d.firstPaintForTransition(transition.published) | |
| 865 | 864 | return nil | |
| @@ -1203,7 +1202,6 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t | |||
| 1203 | 1202 | return nil, attachmentTransitionResult{}, false | |
| 1204 | 1203 | } | |
| 1205 | 1204 | d.touchMRU(targetSess) | |
| 1206 | - | ac.recordPreviousSession(from) | |
| 1207 | 1205 | return targetSess, transition, true | |
| 1208 | 1206 | } | |
| 1209 | 1207 | ||
| @@ -1245,7 +1243,6 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, | |||
| 1245 | 1243 | return targetSess, attachmentTransitionResult{}, false, err | |
| 1246 | 1244 | } | |
| 1247 | 1245 | d.touchMRU(targetSess) | |
| 1248 | - | ac.recordPreviousSession(from) | |
| 1249 | 1246 | return targetSess, transition, true, nil | |
| 1250 | 1247 | } | |
| 1251 | 1248 | ||
| @@ -1276,7 +1273,6 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, | |||
| 1276 | 1273 | return targetSess, attachmentTransitionResult{}, false, err | |
| 1277 | 1274 | } | |
| 1278 | 1275 | d.touchMRU(targetSess) | |
| 1279 | - | ac.recordPreviousSession(from) | |
| 1280 | 1276 | return targetSess, transition, true, nil | |
| 1281 | 1277 | } | |
| 1282 | 1278 | ||
| @@ -859,7 +859,6 @@ func (d *Daemon) switchActiveTargetForAttachmentGuarded(token attachmentConnecti | |||||
| 859 | } | 859 | } | ||
| 860 | } | 860 | } | ||
| 861 | d.touchMRU(targetSess) | 861 | d.touchMRU(targetSess) | ||
| 862 | - | token.ac.recordPreviousSession(token.sess) | |||
| 863 | d.deferAttachmentTransitionCleanups(transition) | 862 | d.deferAttachmentTransitionCleanups(transition) | ||
| 864 | d.firstPaintForTransition(transition.published) | 863 | d.firstPaintForTransition(transition.published) | ||
| 865 | return nil | 864 | return nil | ||
| @@ -1203,7 +1202,6 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t | |||||
| 1203 | return nil, attachmentTransitionResult{}, false | 1202 | return nil, attachmentTransitionResult{}, false | ||
| 1204 | } | 1203 | } | ||
| 1205 | d.touchMRU(targetSess) | 1204 | d.touchMRU(targetSess) | ||
| 1206 | - | ac.recordPreviousSession(from) | |||
| 1207 | return targetSess, transition, true | 1205 | return targetSess, transition, true | ||
| 1208 | } | 1206 | } | ||
| 1209 | 1207 | ||||
| @@ -1245,7 +1243,6 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, | |||||
| 1245 | return targetSess, attachmentTransitionResult{}, false, err | 1243 | return targetSess, attachmentTransitionResult{}, false, err | ||
| 1246 | } | 1244 | } | ||
| 1247 | d.touchMRU(targetSess) | 1245 | d.touchMRU(targetSess) | ||
| 1248 | - | ac.recordPreviousSession(from) | |||
| 1249 | return targetSess, transition, true, nil | 1246 | return targetSess, transition, true, nil | ||
| 1250 | } | 1247 | } | ||
| 1251 | 1248 | ||||
| @@ -1276,7 +1273,6 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, | |||||
| 1276 | return targetSess, attachmentTransitionResult{}, false, err | 1273 | return targetSess, attachmentTransitionResult{}, false, err | ||
| 1277 | } | 1274 | } | ||
| 1278 | d.touchMRU(targetSess) | 1275 | d.touchMRU(targetSess) | ||
| 1279 | - | ac.recordPreviousSession(from) | |||
| 1280 | return targetSess, transition, true, nil | 1276 | return targetSess, transition, true, nil | ||
| 1281 | } | 1277 | } | ||
| 1282 | 1278 | ||||
M internal/usecase/daemon/picker_test.go +0 -41
| @@ -19,7 +19,6 @@ import ( | |||
| 19 | 19 | recoveryusecase "github.com/bnema/vev/internal/usecase/recovery" | |
| 20 | 20 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 21 | 21 | "github.com/bnema/vev/pkg/renderer" | |
| 22 | - | "github.com/bnema/vev/pkg/vt" | |
| 23 | 22 | ) | |
| 24 | 23 | ||
| 25 | 24 | var ( | |
| @@ -952,46 +951,6 @@ func TestPickerLoneEscapeExitsAfterDelay(t *testing.T) { | |||
| 952 | 951 | require.Eventually(t, func() bool { return !ac.overlays.pickerActive() }, time.Second, 5*time.Millisecond) | |
| 953 | 952 | } | |
| 954 | 953 | ||
| 955 | - | func TestBackSessionFirstResetDoesNotReuseSamePaneIDCapture(t *testing.T) { | |
| 956 | - | p1, releasePTY1 := newBlockingPTY(t) | |
| 957 | - | p2, releasePTY2 := newBlockingPTY(t) | |
| 958 | - | defer releasePTY1() | |
| 959 | - | defer releasePTY2() | |
| 960 | - | d, source, ac, sends := newManualSessionWithPTYs(t, p1) | |
| 961 | - | source.id, source.name = "source", "source" | |
| 962 | - | delete(d.sessions, domain.SessionID("manual")) | |
| 963 | - | d.sessions[source.id] = source | |
| 964 | - | target := &session{sessionCore: sessionCore{id: "target", name: "target"}, ctx: source.ctx, cancel: func() {}, | |
| 965 | - | tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 22})}, | |
| 966 | - | } | |
| 967 | - | d.sessions[target.id] = target | |
| 968 | - | ||
| 969 | - | sourcePane := source.tabs[0].focusedPane() | |
| 970 | - | targetPane := target.tabs[0].focusedPane() | |
| 971 | - | require.Equal(t, layout.PaneID("pane-1"), sourcePane.id, "source uses reusable pane-1") | |
| 972 | - | require.Equal(t, sourcePane.id, targetPane.id, "target deliberately reuses pane-1") | |
| 973 | - | sourcePane.screen.Write([]byte("SOURCE")) | |
| 974 | - | client := vt.NewScreen(80, 25) | |
| 975 | - | d.firstPaint(source, ac) | |
| 976 | - | sourceOutput := mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | |
| 977 | - | ac.ackOutputState(sourceOutput.Epoch, sourceOutput.New) | |
| 978 | - | ||
| 979 | - | targetPane.screen.Write([]byte("TARGET")) | |
| 980 | - | targetPane.screen.ClearDamage() // TARGET is already rendered and has no pending VT damage. | |
| 981 | - | require.Empty(t, targetPane.screen.Damage(), "target deliberately has no pending VT damage") | |
| 982 | - | ac.previousSession.Set(target.id) | |
| 983 | - | ||
| 984 | - | // Exercise the user-facing previous-session route, which delegates to the | |
| 985 | - | // real switchToTarget hand-off and immediately emits its required reset. | |
| 986 | - | d.backSession(source, ac) | |
| 987 | - | firstReset := awaitFrame(t, sends, ports.MsgOutput) | |
| 988 | - | out := mustApplyOutput(t, client, firstReset) | |
| 989 | - | require.Zero(t, out.Base, "the first target frame must be the reset, not an eventual repair") | |
| 990 | - | frame := strings.Join(frameRows(client.Frame), "\n") | |
| 991 | - | require.NotContains(t, frame, "SOURCE", "first target reset must not reuse source capture") | |
| 992 | - | require.Contains(t, frame, "TARGET", "first target reset must immediately show clean target VT state") | |
| 993 | - | } | |
| 994 | - | ||
| 995 | 954 | func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) { | |
| 996 | 955 | p1, releasePTY1 := newBlockingPTY(t) | |
| 997 | 956 | p2, releasePTY2 := newBlockingPTY(t) | |
| @@ -19,7 +19,6 @@ import ( | |||||
| 19 | recoveryusecase "github.com/bnema/vev/internal/usecase/recovery" | 19 | recoveryusecase "github.com/bnema/vev/internal/usecase/recovery" | ||
| 20 | themeui "github.com/bnema/vev/internal/usecase/theme" | 20 | themeui "github.com/bnema/vev/internal/usecase/theme" | ||
| 21 | "github.com/bnema/vev/pkg/renderer" | 21 | "github.com/bnema/vev/pkg/renderer" | ||
| 22 | - | "github.com/bnema/vev/pkg/vt" | |||
| 23 | ) | 22 | ) | ||
| 24 | 23 | ||||
| 25 | var ( | 24 | var ( | ||
| @@ -952,46 +951,6 @@ func TestPickerLoneEscapeExitsAfterDelay(t *testing.T) { | |||||
| 952 | require.Eventually(t, func() bool { return !ac.overlays.pickerActive() }, time.Second, 5*time.Millisecond) | 951 | require.Eventually(t, func() bool { return !ac.overlays.pickerActive() }, time.Second, 5*time.Millisecond) | ||
| 953 | } | 952 | } | ||
| 954 | 953 | ||||
| 955 | - | func TestBackSessionFirstResetDoesNotReuseSamePaneIDCapture(t *testing.T) { | |||
| 956 | - | p1, releasePTY1 := newBlockingPTY(t) | |||
| 957 | - | p2, releasePTY2 := newBlockingPTY(t) | |||
| 958 | - | defer releasePTY1() | |||
| 959 | - | defer releasePTY2() | |||
| 960 | - | d, source, ac, sends := newManualSessionWithPTYs(t, p1) | |||
| 961 | - | source.id, source.name = "source", "source" | |||
| 962 | - | delete(d.sessions, domain.SessionID("manual")) | |||
| 963 | - | d.sessions[source.id] = source | |||
| 964 | - | target := &session{sessionCore: sessionCore{id: "target", name: "target"}, ctx: source.ctx, cancel: func() {}, | |||
| 965 | - | tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 22})}, | |||
| 966 | - | } | |||
| 967 | - | d.sessions[target.id] = target | |||
| 968 | - | ||||
| 969 | - | sourcePane := source.tabs[0].focusedPane() | |||
| 970 | - | targetPane := target.tabs[0].focusedPane() | |||
| 971 | - | require.Equal(t, layout.PaneID("pane-1"), sourcePane.id, "source uses reusable pane-1") | |||
| 972 | - | require.Equal(t, sourcePane.id, targetPane.id, "target deliberately reuses pane-1") | |||
| 973 | - | sourcePane.screen.Write([]byte("SOURCE")) | |||
| 974 | - | client := vt.NewScreen(80, 25) | |||
| 975 | - | d.firstPaint(source, ac) | |||
| 976 | - | sourceOutput := mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | |||
| 977 | - | ac.ackOutputState(sourceOutput.Epoch, sourceOutput.New) | |||
| 978 | - | ||||
| 979 | - | targetPane.screen.Write([]byte("TARGET")) | |||
| 980 | - | targetPane.screen.ClearDamage() // TARGET is already rendered and has no pending VT damage. | |||
| 981 | - | require.Empty(t, targetPane.screen.Damage(), "target deliberately has no pending VT damage") | |||
| 982 | - | ac.previousSession.Set(target.id) | |||
| 983 | - | ||||
| 984 | - | // Exercise the user-facing previous-session route, which delegates to the | |||
| 985 | - | // real switchToTarget hand-off and immediately emits its required reset. | |||
| 986 | - | d.backSession(source, ac) | |||
| 987 | - | firstReset := awaitFrame(t, sends, ports.MsgOutput) | |||
| 988 | - | out := mustApplyOutput(t, client, firstReset) | |||
| 989 | - | require.Zero(t, out.Base, "the first target frame must be the reset, not an eventual repair") | |||
| 990 | - | frame := strings.Join(frameRows(client.Frame), "\n") | |||
| 991 | - | require.NotContains(t, frame, "SOURCE", "first target reset must not reuse source capture") | |||
| 992 | - | require.Contains(t, frame, "TARGET", "first target reset must immediately show clean target VT state") | |||
| 993 | - | } | |||
| 994 | - | ||||
| 995 | func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) { | 954 | func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) { | ||
| 996 | p1, releasePTY1 := newBlockingPTY(t) | 955 | p1, releasePTY1 := newBlockingPTY(t) | ||
| 997 | p2, releasePTY2 := newBlockingPTY(t) | 956 | p2, releasePTY2 := newBlockingPTY(t) | ||
M internal/usecase/daemon/recent_route_presentation.go +6 -28
| @@ -1,8 +1,8 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | // recentRouteKind identifies the origin represented by a presentation value. | |
| 4 | - | // It is intentionally daemon-local in this layer; the client route protocol | |
| 5 | - | // and its wire kind are introduced by the next stacked branch. | |
| 4 | + | // It is intentionally daemon-local in this layer; ports route kinds are | |
| 5 | + | // projected into this render-only value at the daemon boundary. | |
| 6 | 6 | type recentRouteKind uint8 | |
| 7 | 7 | ||
| 8 | 8 | const ( | |
| @@ -10,10 +10,10 @@ const ( | |||
| 10 | 10 | recentRouteRemote | |
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | - | // recentRoutePresentation is the compact immutable value shared by collection, | |
| 14 | - | // formatting, and the future client-published snapshot. It contains only | |
| 15 | - | // display data; it never carries session ownership, selection identity, | |
| 16 | - | // transport capabilities, credentials, or attach targets. | |
| 13 | + | // recentRoutePresentation is the compact immutable value shared by snapshot | |
| 14 | + | // projection and formatting. It contains only display data; it never carries | |
| 15 | + | // session ownership, selection identity, transport capabilities, credentials, | |
| 16 | + | // or attach targets. | |
| 17 | 17 | type recentRoutePresentation struct { | |
| 18 | 18 | name string | |
| 19 | 19 | hostLabel string | |
| @@ -31,28 +31,6 @@ type recentRouteDisplay struct { | |||
| 31 | 31 | attention bool | |
| 32 | 32 | } | |
| 33 | 33 | ||
| 34 | - | // routePresentation returns the local daemon's display-only view of a | |
| 35 | - | // selection value. Selection identity remains in recentSession for palette | |
| 36 | - | // execution and is not passed into rendering. | |
| 37 | - | func (entry recentSession) routePresentation() recentRoutePresentation { | |
| 38 | - | return recentRoutePresentation{ | |
| 39 | - | name: entry.name, | |
| 40 | - | kind: recentRouteLocal, | |
| 41 | - | attention: entry.attention, | |
| 42 | - | } | |
| 43 | - | } | |
| 44 | - | ||
| 45 | - | func recentRoutePresentations(entries []recentSession) []recentRoutePresentation { | |
| 46 | - | if entries == nil { | |
| 47 | - | return nil | |
| 48 | - | } | |
| 49 | - | out := make([]recentRoutePresentation, len(entries)) | |
| 50 | - | for i, entry := range entries { | |
| 51 | - | out[i] = entry.routePresentation() | |
| 52 | - | } | |
| 53 | - | return out | |
| 54 | - | } | |
| 55 | - | ||
| 56 | 34 | // formatRecentRoutePresentations is the single label formatter used by the | |
| 57 | 35 | // normal MRU bar and ranked recent-session palette rendering. It returns an | |
| 58 | 36 | // owned slice so callers can retain the result without consulting live state. | |
| @@ -1,8 +1,8 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | // recentRouteKind identifies the origin represented by a presentation value. | 3 | // recentRouteKind identifies the origin represented by a presentation value. | ||
| 4 | - | // It is intentionally daemon-local in this layer; the client route protocol | 4 | + | // It is intentionally daemon-local in this layer; ports route kinds are |
| 5 | - | // and its wire kind are introduced by the next stacked branch. | 5 | + | // projected into this render-only value at the daemon boundary. |
| 6 | type recentRouteKind uint8 | 6 | type recentRouteKind uint8 | ||
| 7 | 7 | ||||
| 8 | const ( | 8 | const ( | ||
| @@ -10,10 +10,10 @@ const ( | |||||
| 10 | recentRouteRemote | 10 | recentRouteRemote | ||
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
| 13 | - | // recentRoutePresentation is the compact immutable value shared by collection, | 13 | + | // recentRoutePresentation is the compact immutable value shared by snapshot |
| 14 | - | // formatting, and the future client-published snapshot. It contains only | 14 | + | // projection and formatting. It contains only display data; it never carries |
| 15 | - | // display data; it never carries session ownership, selection identity, | 15 | + | // session ownership, selection identity, transport capabilities, credentials, |
| 16 | - | // transport capabilities, credentials, or attach targets. | 16 | + | // or attach targets. |
| 17 | type recentRoutePresentation struct { | 17 | type recentRoutePresentation struct { | ||
| 18 | name string | 18 | name string | ||
| 19 | hostLabel string | 19 | hostLabel string | ||
| @@ -31,28 +31,6 @@ type recentRouteDisplay struct { | |||||
| 31 | attention bool | 31 | attention bool | ||
| 32 | } | 32 | } | ||
| 33 | 33 | ||||
| 34 | - | // routePresentation returns the local daemon's display-only view of a | |||
| 35 | - | // selection value. Selection identity remains in recentSession for palette | |||
| 36 | - | // execution and is not passed into rendering. | |||
| 37 | - | func (entry recentSession) routePresentation() recentRoutePresentation { | |||
| 38 | - | return recentRoutePresentation{ | |||
| 39 | - | name: entry.name, | |||
| 40 | - | kind: recentRouteLocal, | |||
| 41 | - | attention: entry.attention, | |||
| 42 | - | } | |||
| 43 | - | } | |||
| 44 | - | ||||
| 45 | - | func recentRoutePresentations(entries []recentSession) []recentRoutePresentation { | |||
| 46 | - | if entries == nil { | |||
| 47 | - | return nil | |||
| 48 | - | } | |||
| 49 | - | out := make([]recentRoutePresentation, len(entries)) | |||
| 50 | - | for i, entry := range entries { | |||
| 51 | - | out[i] = entry.routePresentation() | |||
| 52 | - | } | |||
| 53 | - | return out | |||
| 54 | - | } | |||
| 55 | - | ||||
| 56 | // formatRecentRoutePresentations is the single label formatter used by the | 34 | // formatRecentRoutePresentations is the single label formatter used by the | ||
| 57 | // normal MRU bar and ranked recent-session palette rendering. It returns an | 35 | // normal MRU bar and ranked recent-session palette rendering. It returns an | ||
| 58 | // owned slice so callers can retain the result without consulting live state. | 36 | // owned slice so callers can retain the result without consulting live state. | ||
M internal/usecase/daemon/recent_route_presentation_test.go +11 -17
| @@ -3,6 +3,7 @@ package daemon | |||
| 3 | 3 | import ( | |
| 4 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | + | "github.com/bnema/vev/internal/ports" | |
| 6 | 7 | "github.com/bnema/vev/internal/usecase/command" | |
| 7 | 8 | "github.com/bnema/vev/internal/usecase/palette" | |
| 8 | 9 | "github.com/stretchr/testify/require" | |
| @@ -74,26 +75,19 @@ func TestFormatRecentRoutePresentations(t *testing.T) { | |||
| 74 | 75 | } | |
| 75 | 76 | } | |
| 76 | 77 | ||
| 77 | - | func TestRecentRoutePresentationsProjectSelectionValues(t *testing.T) { | |
| 78 | - | entries := []recentSession{{id: "session-secret", name: "work", attention: true, mruAt: 17}} | |
| 79 | - | ||
| 80 | - | got := recentRoutePresentations(entries) | |
| 81 | - | ||
| 82 | - | require.Equal(t, []recentRoutePresentation{{ | |
| 83 | - | name: "work", kind: recentRouteLocal, attention: true, | |
| 84 | - | }}, got) | |
| 85 | - | } | |
| 86 | - | ||
| 87 | 78 | func TestRecentSessionHintsAndRankedRenderingShareCanonicalLabels(t *testing.T) { | |
| 88 | - | recent := []recentSession{ | |
| 89 | - | {name: "logs", attention: true}, | |
| 90 | - | {name: "logs"}, | |
| 79 | + | snapshot := ports.RecentRouteSnapshot{ | |
| 80 | + | Generation: 1, | |
| 81 | + | Entries: []ports.RecentRouteEntry{ | |
| 82 | + | {Key: 1, Generation: 1, Name: "logs", Kind: ports.RouteKindLocal, Attention: true}, | |
| 83 | + | {Key: 2, Generation: 1, Name: "logs", Kind: ports.RouteKindLocal}, | |
| 84 | + | }, | |
| 91 | 85 | } | |
| 92 | 86 | ||
| 93 | - | hints := recentSessionHints(recent, nil) | |
| 87 | + | hints := recentRouteHints(snapshot, nil) | |
| 94 | 88 | require.Equal(t, []palette.RecentSessionHint{ | |
| 95 | - | {Rank: 1, Name: "logs@local"}, | |
| 96 | - | {Rank: 2, Name: "logs@local"}, | |
| 89 | + | {Rank: 1, Name: "logs@local", SnapshotGeneration: 1, Key: 1, Generation: 1}, | |
| 90 | + | {Rank: 2, Name: "logs@local", SnapshotGeneration: 1, Key: 2, Generation: 1}, | |
| 97 | 91 | }, hints.Recent) | |
| 98 | 92 | ||
| 99 | 93 | hints = palette.ContextualHints{ | |
| @@ -104,7 +98,7 @@ func TestRecentSessionHintsAndRankedRenderingShareCanonicalLabels(t *testing.T) | |||
| 104 | 98 | {Rank: 9, Name: "stale-label"}, | |
| 105 | 99 | }, | |
| 106 | 100 | } | |
| 107 | - | ranked := rankedRecentForHints(&hints, recent) | |
| 101 | + | ranked := rankedRecentForHintsWithSnapshot(&hints, snapshot) | |
| 108 | 102 | ||
| 109 | 103 | require.Equal(t, []rankedRecent{ | |
| 110 | 104 | {rank: 4, name: "logs@local", kind: recentRouteLocal, attention: true}, | |
| @@ -3,6 +3,7 @@ package daemon | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "testing" | 4 | "testing" | ||
| 5 | 5 | ||||
| 6 | + | "github.com/bnema/vev/internal/ports" | |||
| 6 | "github.com/bnema/vev/internal/usecase/command" | 7 | "github.com/bnema/vev/internal/usecase/command" | ||
| 7 | "github.com/bnema/vev/internal/usecase/palette" | 8 | "github.com/bnema/vev/internal/usecase/palette" | ||
| 8 | "github.com/stretchr/testify/require" | 9 | "github.com/stretchr/testify/require" | ||
| @@ -74,26 +75,19 @@ func TestFormatRecentRoutePresentations(t *testing.T) { | |||||
| 74 | } | 75 | } | ||
| 75 | } | 76 | } | ||
| 76 | 77 | ||||
| 77 | - | func TestRecentRoutePresentationsProjectSelectionValues(t *testing.T) { | |||
| 78 | - | entries := []recentSession{{id: "session-secret", name: "work", attention: true, mruAt: 17}} | |||
| 79 | - | ||||
| 80 | - | got := recentRoutePresentations(entries) | |||
| 81 | - | ||||
| 82 | - | require.Equal(t, []recentRoutePresentation{{ | |||
| 83 | - | name: "work", kind: recentRouteLocal, attention: true, | |||
| 84 | - | }}, got) | |||
| 85 | - | } | |||
| 86 | - | ||||
| 87 | func TestRecentSessionHintsAndRankedRenderingShareCanonicalLabels(t *testing.T) { | 78 | func TestRecentSessionHintsAndRankedRenderingShareCanonicalLabels(t *testing.T) { | ||
| 88 | - | recent := []recentSession{ | 79 | + | snapshot := ports.RecentRouteSnapshot{ |
| 89 | - | {name: "logs", attention: true}, | 80 | + | Generation: 1, |
| 90 | - | {name: "logs"}, | 81 | + | Entries: []ports.RecentRouteEntry{ |
| 82 | + | {Key: 1, Generation: 1, Name: "logs", Kind: ports.RouteKindLocal, Attention: true}, | |||
| 83 | + | {Key: 2, Generation: 1, Name: "logs", Kind: ports.RouteKindLocal}, | |||
| 84 | + | }, | |||
| 91 | } | 85 | } | ||
| 92 | 86 | ||||
| 93 | - | hints := recentSessionHints(recent, nil) | 87 | + | hints := recentRouteHints(snapshot, nil) |
| 94 | require.Equal(t, []palette.RecentSessionHint{ | 88 | require.Equal(t, []palette.RecentSessionHint{ | ||
| 95 | - | {Rank: 1, Name: "logs@local"}, | 89 | + | {Rank: 1, Name: "logs@local", SnapshotGeneration: 1, Key: 1, Generation: 1}, |
| 96 | - | {Rank: 2, Name: "logs@local"}, | 90 | + | {Rank: 2, Name: "logs@local", SnapshotGeneration: 1, Key: 2, Generation: 1}, |
| 97 | }, hints.Recent) | 91 | }, hints.Recent) | ||
| 98 | 92 | ||||
| 99 | hints = palette.ContextualHints{ | 93 | hints = palette.ContextualHints{ | ||
| @@ -104,7 +98,7 @@ func TestRecentSessionHintsAndRankedRenderingShareCanonicalLabels(t *testing.T) | |||||
| 104 | {Rank: 9, Name: "stale-label"}, | 98 | {Rank: 9, Name: "stale-label"}, | ||
| 105 | }, | 99 | }, | ||
| 106 | } | 100 | } | ||
| 107 | - | ranked := rankedRecentForHints(&hints, recent) | 101 | + | ranked := rankedRecentForHintsWithSnapshot(&hints, snapshot) |
| 108 | 102 | ||||
| 109 | require.Equal(t, []rankedRecent{ | 103 | require.Equal(t, []rankedRecent{ | ||
| 110 | {rank: 4, name: "logs@local", kind: recentRouteLocal, attention: true}, | 104 | {rank: 4, name: "logs@local", kind: recentRouteLocal, attention: true}, | ||
A internal/usecase/daemon/recent_route_snapshot.go +28 -0
| @@ -0,0 +1,28 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import "github.com/bnema/vev/internal/ports" | |
| 4 | + | ||
| 5 | + | func recentRoutePresentationsFromSnapshot(snapshot ports.RecentRouteSnapshot) []recentRoutePresentation { | |
| 6 | + | if snapshot.Entries == nil { | |
| 7 | + | return nil | |
| 8 | + | } | |
| 9 | + | out := make([]recentRoutePresentation, len(snapshot.Entries)) | |
| 10 | + | for i, entry := range snapshot.Entries { | |
| 11 | + | kind := recentRouteLocal | |
| 12 | + | if entry.Kind == ports.RouteKindRemote { | |
| 13 | + | kind = recentRouteRemote | |
| 14 | + | } | |
| 15 | + | out[i] = recentRoutePresentation{ | |
| 16 | + | name: entry.Name, | |
| 17 | + | hostLabel: entry.HostLabel, | |
| 18 | + | kind: kind, | |
| 19 | + | ephemeral: entry.Ephemeral, | |
| 20 | + | attention: entry.Attention, | |
| 21 | + | } | |
| 22 | + | } | |
| 23 | + | return out | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func formatRecentRouteSnapshot(snapshot ports.RecentRouteSnapshot) []recentRouteDisplay { | |
| 27 | + | return formatRecentRoutePresentations(recentRoutePresentationsFromSnapshot(snapshot)) | |
| 28 | + | } | |
| @@ -0,0 +1,28 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import "github.com/bnema/vev/internal/ports" | |||
| 4 | + | ||||
| 5 | + | func recentRoutePresentationsFromSnapshot(snapshot ports.RecentRouteSnapshot) []recentRoutePresentation { | |||
| 6 | + | if snapshot.Entries == nil { | |||
| 7 | + | return nil | |||
| 8 | + | } | |||
| 9 | + | out := make([]recentRoutePresentation, len(snapshot.Entries)) | |||
| 10 | + | for i, entry := range snapshot.Entries { | |||
| 11 | + | kind := recentRouteLocal | |||
| 12 | + | if entry.Kind == ports.RouteKindRemote { | |||
| 13 | + | kind = recentRouteRemote | |||
| 14 | + | } | |||
| 15 | + | out[i] = recentRoutePresentation{ | |||
| 16 | + | name: entry.Name, | |||
| 17 | + | hostLabel: entry.HostLabel, | |||
| 18 | + | kind: kind, | |||
| 19 | + | ephemeral: entry.Ephemeral, | |||
| 20 | + | attention: entry.Attention, | |||
| 21 | + | } | |||
| 22 | + | } | |||
| 23 | + | return out | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func formatRecentRouteSnapshot(snapshot ports.RecentRouteSnapshot) []recentRouteDisplay { | |||
| 27 | + | return formatRecentRoutePresentations(recentRoutePresentationsFromSnapshot(snapshot)) | |||
| 28 | + | } | |||
A internal/usecase/daemon/recent_route_snapshot_test.go +92 -0
| @@ -0,0 +1,92 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/ports" | |
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestAttachmentStatusUsesClientRouteSnapshot(t *testing.T) { | |
| 11 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, nil) | |
| 12 | + | ac.setRouteSnapshot(ports.RecentRouteSnapshot{ | |
| 13 | + | Generation: 2, | |
| 14 | + | Active: ports.RouteRef{Key: 3, Generation: 2}, | |
| 15 | + | Entries: []ports.RecentRouteEntry{ | |
| 16 | + | {Key: 2, Generation: 1, Name: "logs", HostLabel: "edge", Kind: ports.RouteKindRemote}, | |
| 17 | + | {Key: 1, Generation: 1, Name: "work", Kind: ports.RouteKindLocal}, | |
| 18 | + | }, | |
| 19 | + | }) | |
| 20 | + | ||
| 21 | + | state := d.barStateForAttachmentPaletteHintsFor(sess, ac, "", nil, ports.RecentRouteSnapshot{}) | |
| 22 | + | ||
| 23 | + | require.Equal(t, []string{"logs@edge", "work"}, []string{state.mru[0].name, state.mru[1].name}) | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func TestAttachmentRouteSnapshotCopiesPublishedEntries(t *testing.T) { | |
| 27 | + | _, _, ac, _ := newManualSessionWithPTYs(t, nil) | |
| 28 | + | entries := []ports.RecentRouteEntry{{Key: 1, Generation: 2, Name: "before", Kind: ports.RouteKindLocal}} | |
| 29 | + | snapshot := ports.RecentRouteSnapshot{Generation: 3, Entries: entries} | |
| 30 | + | ac.setRouteSnapshot(snapshot) | |
| 31 | + | ||
| 32 | + | entries[0].Name = "after" | |
| 33 | + | got := ac.routeSnapshotCopy() | |
| 34 | + | require.Equal(t, "before", got.Entries[0].Name) | |
| 35 | + | got.Entries[0].Name = "mutated copy" | |
| 36 | + | require.Equal(t, "before", ac.routeSnapshotCopy().Entries[0].Name) | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func TestPaletteRecentRouteSelectionSendsTypedClientAction(t *testing.T) { | |
| 40 | + | d, source, ac, _ := newManualSessionWithPTYs(t, nil) | |
| 41 | + | transport := &closeTrackingTransport{} | |
| 42 | + | ac.replaceTransport(transport) | |
| 43 | + | rc := d.attachCoordinator(source, nil, ac, true) | |
| 44 | + | token := source.attachmentToken(ac, transport) | |
| 45 | + | token.lease = rc.attachmentLease(ac) | |
| 46 | + | ac.publishAttachmentCapability(token) | |
| 47 | + | effect, admitted := ac.beginAttachmentEffect(token) | |
| 48 | + | require.True(t, admitted) | |
| 49 | + | defer effect.End() | |
| 50 | + | token.effect = effect | |
| 51 | + | ||
| 52 | + | exec := paletteExec{ | |
| 53 | + | d: d, sess: source, attachment: source, ac: ac, | |
| 54 | + | routeSnapshot: ports.RecentRouteSnapshot{ | |
| 55 | + | Generation: 4, | |
| 56 | + | Entries: []ports.RecentRouteEntry{{Key: 2, Generation: 3, Name: "remote", Kind: ports.RouteKindRemote}}, | |
| 57 | + | }, | |
| 58 | + | effect: effect, | |
| 59 | + | } | |
| 60 | + | require.NoError(t, exec.JumpRecentSession(1)) | |
| 61 | + | ||
| 62 | + | frames := transport.Sends() | |
| 63 | + | var action ports.RouteNavigationAction | |
| 64 | + | found := false | |
| 65 | + | for _, frame := range frames { | |
| 66 | + | if frame.Type != ports.MsgNavigateRecentRoute { | |
| 67 | + | continue | |
| 68 | + | } | |
| 69 | + | var err error | |
| 70 | + | action, err = ports.UnmarshalRouteNavigationAction(frame.Payload) | |
| 71 | + | require.NoError(t, err) | |
| 72 | + | found = true | |
| 73 | + | } | |
| 74 | + | require.True(t, found) | |
| 75 | + | require.Equal(t, ports.RouteNavigationAction{SnapshotGeneration: 4, Key: 2, Generation: 3}, action) | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func TestRecentRouteHintsRetainSnapshotSelectionIdentity(t *testing.T) { | |
| 79 | + | snapshot := ports.RecentRouteSnapshot{ | |
| 80 | + | Generation: 8, | |
| 81 | + | Entries: []ports.RecentRouteEntry{{ | |
| 82 | + | Key: 11, Generation: 7, Name: "logs", HostLabel: "edge", Kind: ports.RouteKindRemote, | |
| 83 | + | }}, | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | hints := recentRouteHints(snapshot, nil) | |
| 87 | + | ||
| 88 | + | require.Equal(t, "logs@edge", hints.Recent[0].Name) | |
| 89 | + | require.Equal(t, uint64(8), hints.Recent[0].SnapshotGeneration) | |
| 90 | + | require.Equal(t, uint64(11), hints.Recent[0].Key) | |
| 91 | + | require.Equal(t, uint64(7), hints.Recent[0].Generation) | |
| 92 | + | } | |
| @@ -0,0 +1,92 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/ports" | |||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestAttachmentStatusUsesClientRouteSnapshot(t *testing.T) { | |||
| 11 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, nil) | |||
| 12 | + | ac.setRouteSnapshot(ports.RecentRouteSnapshot{ | |||
| 13 | + | Generation: 2, | |||
| 14 | + | Active: ports.RouteRef{Key: 3, Generation: 2}, | |||
| 15 | + | Entries: []ports.RecentRouteEntry{ | |||
| 16 | + | {Key: 2, Generation: 1, Name: "logs", HostLabel: "edge", Kind: ports.RouteKindRemote}, | |||
| 17 | + | {Key: 1, Generation: 1, Name: "work", Kind: ports.RouteKindLocal}, | |||
| 18 | + | }, | |||
| 19 | + | }) | |||
| 20 | + | ||||
| 21 | + | state := d.barStateForAttachmentPaletteHintsFor(sess, ac, "", nil, ports.RecentRouteSnapshot{}) | |||
| 22 | + | ||||
| 23 | + | require.Equal(t, []string{"logs@edge", "work"}, []string{state.mru[0].name, state.mru[1].name}) | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func TestAttachmentRouteSnapshotCopiesPublishedEntries(t *testing.T) { | |||
| 27 | + | _, _, ac, _ := newManualSessionWithPTYs(t, nil) | |||
| 28 | + | entries := []ports.RecentRouteEntry{{Key: 1, Generation: 2, Name: "before", Kind: ports.RouteKindLocal}} | |||
| 29 | + | snapshot := ports.RecentRouteSnapshot{Generation: 3, Entries: entries} | |||
| 30 | + | ac.setRouteSnapshot(snapshot) | |||
| 31 | + | ||||
| 32 | + | entries[0].Name = "after" | |||
| 33 | + | got := ac.routeSnapshotCopy() | |||
| 34 | + | require.Equal(t, "before", got.Entries[0].Name) | |||
| 35 | + | got.Entries[0].Name = "mutated copy" | |||
| 36 | + | require.Equal(t, "before", ac.routeSnapshotCopy().Entries[0].Name) | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func TestPaletteRecentRouteSelectionSendsTypedClientAction(t *testing.T) { | |||
| 40 | + | d, source, ac, _ := newManualSessionWithPTYs(t, nil) | |||
| 41 | + | transport := &closeTrackingTransport{} | |||
| 42 | + | ac.replaceTransport(transport) | |||
| 43 | + | rc := d.attachCoordinator(source, nil, ac, true) | |||
| 44 | + | token := source.attachmentToken(ac, transport) | |||
| 45 | + | token.lease = rc.attachmentLease(ac) | |||
| 46 | + | ac.publishAttachmentCapability(token) | |||
| 47 | + | effect, admitted := ac.beginAttachmentEffect(token) | |||
| 48 | + | require.True(t, admitted) | |||
| 49 | + | defer effect.End() | |||
| 50 | + | token.effect = effect | |||
| 51 | + | ||||
| 52 | + | exec := paletteExec{ | |||
| 53 | + | d: d, sess: source, attachment: source, ac: ac, | |||
| 54 | + | routeSnapshot: ports.RecentRouteSnapshot{ | |||
| 55 | + | Generation: 4, | |||
| 56 | + | Entries: []ports.RecentRouteEntry{{Key: 2, Generation: 3, Name: "remote", Kind: ports.RouteKindRemote}}, | |||
| 57 | + | }, | |||
| 58 | + | effect: effect, | |||
| 59 | + | } | |||
| 60 | + | require.NoError(t, exec.JumpRecentSession(1)) | |||
| 61 | + | ||||
| 62 | + | frames := transport.Sends() | |||
| 63 | + | var action ports.RouteNavigationAction | |||
| 64 | + | found := false | |||
| 65 | + | for _, frame := range frames { | |||
| 66 | + | if frame.Type != ports.MsgNavigateRecentRoute { | |||
| 67 | + | continue | |||
| 68 | + | } | |||
| 69 | + | var err error | |||
| 70 | + | action, err = ports.UnmarshalRouteNavigationAction(frame.Payload) | |||
| 71 | + | require.NoError(t, err) | |||
| 72 | + | found = true | |||
| 73 | + | } | |||
| 74 | + | require.True(t, found) | |||
| 75 | + | require.Equal(t, ports.RouteNavigationAction{SnapshotGeneration: 4, Key: 2, Generation: 3}, action) | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | func TestRecentRouteHintsRetainSnapshotSelectionIdentity(t *testing.T) { | |||
| 79 | + | snapshot := ports.RecentRouteSnapshot{ | |||
| 80 | + | Generation: 8, | |||
| 81 | + | Entries: []ports.RecentRouteEntry{{ | |||
| 82 | + | Key: 11, Generation: 7, Name: "logs", HostLabel: "edge", Kind: ports.RouteKindRemote, | |||
| 83 | + | }}, | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | hints := recentRouteHints(snapshot, nil) | |||
| 87 | + | ||||
| 88 | + | require.Equal(t, "logs@edge", hints.Recent[0].Name) | |||
| 89 | + | require.Equal(t, uint64(8), hints.Recent[0].SnapshotGeneration) | |||
| 90 | + | require.Equal(t, uint64(11), hints.Recent[0].Key) | |||
| 91 | + | require.Equal(t, uint64(7), hints.Recent[0].Generation) | |||
| 92 | + | } | |||
D internal/usecase/daemon/recent_sessions.go +0 -50
| @@ -1,50 +0,0 @@ | |||
| 1 | - | package daemon | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "sort" | |
| 5 | - | ||
| 6 | - | "github.com/bnema/vev/internal/domain" | |
| 7 | - | ) | |
| 8 | - | ||
| 9 | - | // recentSession is the immutable selection snapshot for one interaction. Its | |
| 10 | - | // identity and MRU sequence are retained only for daemon-side execution and | |
| 11 | - | // ordering; render paths project it into recentRoutePresentation, which has no | |
| 12 | - | // selection identity or lifecycle authority. | |
| 13 | - | type recentSession struct { | |
| 14 | - | id domain.SessionID | |
| 15 | - | name string | |
| 16 | - | attention bool | |
| 17 | - | mruAt uint64 | |
| 18 | - | } | |
| 19 | - | ||
| 20 | - | // recentSessions returns the current session's capped named-session MRU list. | |
| 21 | - | // Callers may retain the returned values for the lifetime of an interaction. | |
| 22 | - | func (d *Daemon) recentSessions(current *session) []recentSession { | |
| 23 | - | if d == nil { | |
| 24 | - | return nil | |
| 25 | - | } | |
| 26 | - | d.mu.Lock() | |
| 27 | - | recent := make([]recentSession, 0, len(d.sessions)) | |
| 28 | - | for _, sess := range d.sessions { | |
| 29 | - | if sess == nil || sess == current { | |
| 30 | - | continue | |
| 31 | - | } | |
| 32 | - | snap := sess.snapshotView(viewOptions{}) | |
| 33 | - | if snap.ephemeral { | |
| 34 | - | continue | |
| 35 | - | } | |
| 36 | - | entry := recentSession{id: snap.id, name: snap.name, mruAt: snap.mruAt, attention: snap.hasAttention} | |
| 37 | - | recent = append(recent, entry) | |
| 38 | - | } | |
| 39 | - | d.mu.Unlock() | |
| 40 | - | sort.SliceStable(recent, func(i, j int) bool { | |
| 41 | - | if recent[i].mruAt == recent[j].mruAt { | |
| 42 | - | return recent[i].name < recent[j].name | |
| 43 | - | } | |
| 44 | - | return recent[i].mruAt > recent[j].mruAt | |
| 45 | - | }) | |
| 46 | - | if len(recent) > maxMRUSessions { | |
| 47 | - | recent = recent[:maxMRUSessions] | |
| 48 | - | } | |
| 49 | - | return recent | |
| 50 | - | } | |
| @@ -1,50 +0,0 @@ | |||||
| 1 | - | package daemon | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "sort" | |||
| 5 | - | ||||
| 6 | - | "github.com/bnema/vev/internal/domain" | |||
| 7 | - | ) | |||
| 8 | - | ||||
| 9 | - | // recentSession is the immutable selection snapshot for one interaction. Its | |||
| 10 | - | // identity and MRU sequence are retained only for daemon-side execution and | |||
| 11 | - | // ordering; render paths project it into recentRoutePresentation, which has no | |||
| 12 | - | // selection identity or lifecycle authority. | |||
| 13 | - | type recentSession struct { | |||
| 14 | - | id domain.SessionID | |||
| 15 | - | name string | |||
| 16 | - | attention bool | |||
| 17 | - | mruAt uint64 | |||
| 18 | - | } | |||
| 19 | - | ||||
| 20 | - | // recentSessions returns the current session's capped named-session MRU list. | |||
| 21 | - | // Callers may retain the returned values for the lifetime of an interaction. | |||
| 22 | - | func (d *Daemon) recentSessions(current *session) []recentSession { | |||
| 23 | - | if d == nil { | |||
| 24 | - | return nil | |||
| 25 | - | } | |||
| 26 | - | d.mu.Lock() | |||
| 27 | - | recent := make([]recentSession, 0, len(d.sessions)) | |||
| 28 | - | for _, sess := range d.sessions { | |||
| 29 | - | if sess == nil || sess == current { | |||
| 30 | - | continue | |||
| 31 | - | } | |||
| 32 | - | snap := sess.snapshotView(viewOptions{}) | |||
| 33 | - | if snap.ephemeral { | |||
| 34 | - | continue | |||
| 35 | - | } | |||
| 36 | - | entry := recentSession{id: snap.id, name: snap.name, mruAt: snap.mruAt, attention: snap.hasAttention} | |||
| 37 | - | recent = append(recent, entry) | |||
| 38 | - | } | |||
| 39 | - | d.mu.Unlock() | |||
| 40 | - | sort.SliceStable(recent, func(i, j int) bool { | |||
| 41 | - | if recent[i].mruAt == recent[j].mruAt { | |||
| 42 | - | return recent[i].name < recent[j].name | |||
| 43 | - | } | |||
| 44 | - | return recent[i].mruAt > recent[j].mruAt | |||
| 45 | - | }) | |||
| 46 | - | if len(recent) > maxMRUSessions { | |||
| 47 | - | recent = recent[:maxMRUSessions] | |||
| 48 | - | } | |||
| 49 | - | return recent | |||
| 50 | - | } | |||
M internal/usecase/daemon/remote_attach.go +25 -0
| @@ -88,6 +88,31 @@ func (d *Daemon) sendNavigationActionForAttachment(token attachmentConnectionTok | |||
| 88 | 88 | return token.sendControl(ports.Frame{Type: ports.MsgNavigationAction, Payload: payload}) | |
| 89 | 89 | } | |
| 90 | 90 | ||
| 91 | + | func (d *Daemon) sendRecentRouteNavigationActionForAttachment(token attachmentConnectionToken, action ports.RouteNavigationAction) error { | |
| 92 | + | payload, err := ports.MarshalRouteNavigationAction(action) | |
| 93 | + | if err != nil { | |
| 94 | + | return errAttachmentTransition | |
| 95 | + | } | |
| 96 | + | return token.sendControl(ports.Frame{Type: ports.MsgNavigateRecentRoute, Payload: payload}) | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func (d *Daemon) sendCommittedRouteIdentityForAttachment(token attachmentConnectionToken) error { | |
| 100 | + | if token.sess == nil { | |
| 101 | + | return errAttachmentTransition | |
| 102 | + | } | |
| 103 | + | token.sess.mu.Lock() | |
| 104 | + | identity := ports.CommittedRouteIdentity{ | |
| 105 | + | Target: ports.ExactSessionTarget{LifecycleID: token.sess.incarnation, SessionName: token.sess.name}, | |
| 106 | + | Ephemeral: token.sess.ephemeral, | |
| 107 | + | } | |
| 108 | + | token.sess.mu.Unlock() | |
| 109 | + | payload, err := ports.MarshalCommittedRouteIdentity(identity) | |
| 110 | + | if err != nil { | |
| 111 | + | return errAttachmentTransition | |
| 112 | + | } | |
| 113 | + | return token.sendControl(ports.Frame{Type: ports.MsgCommittedRouteIdentity, Payload: payload}) | |
| 114 | + | } | |
| 115 | + | ||
| 91 | 116 | func (d *Daemon) finishRouteAttach(sess *session, tr ports.Transport, sz domain.Size, term terminalEnv, h ports.Hello, routeCreated, purge bool) (*attachedClient, error) { | |
| 92 | 117 | ac, err := d.finishAttach(sess, tr, sz, term, h) | |
| 93 | 118 | if err != nil && routeCreated { | |
| @@ -88,6 +88,31 @@ func (d *Daemon) sendNavigationActionForAttachment(token attachmentConnectionTok | |||||
| 88 | return token.sendControl(ports.Frame{Type: ports.MsgNavigationAction, Payload: payload}) | 88 | return token.sendControl(ports.Frame{Type: ports.MsgNavigationAction, Payload: payload}) | ||
| 89 | } | 89 | } | ||
| 90 | 90 | ||||
| 91 | + | func (d *Daemon) sendRecentRouteNavigationActionForAttachment(token attachmentConnectionToken, action ports.RouteNavigationAction) error { | |||
| 92 | + | payload, err := ports.MarshalRouteNavigationAction(action) | |||
| 93 | + | if err != nil { | |||
| 94 | + | return errAttachmentTransition | |||
| 95 | + | } | |||
| 96 | + | return token.sendControl(ports.Frame{Type: ports.MsgNavigateRecentRoute, Payload: payload}) | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | func (d *Daemon) sendCommittedRouteIdentityForAttachment(token attachmentConnectionToken) error { | |||
| 100 | + | if token.sess == nil { | |||
| 101 | + | return errAttachmentTransition | |||
| 102 | + | } | |||
| 103 | + | token.sess.mu.Lock() | |||
| 104 | + | identity := ports.CommittedRouteIdentity{ | |||
| 105 | + | Target: ports.ExactSessionTarget{LifecycleID: token.sess.incarnation, SessionName: token.sess.name}, | |||
| 106 | + | Ephemeral: token.sess.ephemeral, | |||
| 107 | + | } | |||
| 108 | + | token.sess.mu.Unlock() | |||
| 109 | + | payload, err := ports.MarshalCommittedRouteIdentity(identity) | |||
| 110 | + | if err != nil { | |||
| 111 | + | return errAttachmentTransition | |||
| 112 | + | } | |||
| 113 | + | return token.sendControl(ports.Frame{Type: ports.MsgCommittedRouteIdentity, Payload: payload}) | |||
| 114 | + | } | |||
| 115 | + | ||||
| 91 | func (d *Daemon) finishRouteAttach(sess *session, tr ports.Transport, sz domain.Size, term terminalEnv, h ports.Hello, routeCreated, purge bool) (*attachedClient, error) { | 116 | func (d *Daemon) finishRouteAttach(sess *session, tr ports.Transport, sz domain.Size, term terminalEnv, h ports.Hello, routeCreated, purge bool) (*attachedClient, error) { | ||
| 92 | ac, err := d.finishAttach(sess, tr, sz, term, h) | 117 | ac, err := d.finishAttach(sess, tr, sz, term, h) | ||
| 93 | if err != nil && routeCreated { | 118 | if err != nil && routeCreated { | ||
M internal/usecase/daemon/render.go +1 -1
| @@ -388,7 +388,7 @@ func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *at | |||
| 388 | 388 | if statusFeedback == "" && overlays.resizeActive { | |
| 389 | 389 | statusFeedback = "resize: h/j/k/l or arrows · = equalize · q/esc/enter exit" | |
| 390 | 390 | } | |
| 391 | - | bars := d.barStateForAttachmentPaletteHintsFor(entry, ac, statusFeedback, overlays.paletteHints, overlays.paletteRecent) | |
| 391 | + | bars := d.barStateForAttachmentPaletteHintsFor(entry, ac, statusFeedback, overlays.paletteHints, overlays.paletteRouteSnapshot) | |
| 392 | 392 | applied := ac.getAppliedTheme() | |
| 393 | 393 | bars.theme = applied.Raw | |
| 394 | 394 | if local { | |
| @@ -388,7 +388,7 @@ func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *at | |||||
| 388 | if statusFeedback == "" && overlays.resizeActive { | 388 | if statusFeedback == "" && overlays.resizeActive { | ||
| 389 | statusFeedback = "resize: h/j/k/l or arrows · = equalize · q/esc/enter exit" | 389 | statusFeedback = "resize: h/j/k/l or arrows · = equalize · q/esc/enter exit" | ||
| 390 | } | 390 | } | ||
| 391 | - | bars := d.barStateForAttachmentPaletteHintsFor(entry, ac, statusFeedback, overlays.paletteHints, overlays.paletteRecent) | 391 | + | bars := d.barStateForAttachmentPaletteHintsFor(entry, ac, statusFeedback, overlays.paletteHints, overlays.paletteRouteSnapshot) |
| 392 | applied := ac.getAppliedTheme() | 392 | applied := ac.getAppliedTheme() | ||
| 393 | bars.theme = applied.Raw | 393 | bars.theme = applied.Raw | ||
| 394 | if local { | 394 | if local { | ||
M internal/usecase/daemon/render_coordinator_test.go +1 -9
| @@ -1136,7 +1136,7 @@ func TestRenderCoordinatorResizeMetadata(t *testing.T) { | |||
| 1136 | 1136 | var producerFiles = []string{ | |
| 1137 | 1137 | "attention.go", "client.go", "copymode.go", "floating.go", "input.go", | |
| 1138 | 1138 | "palette.go", "pane_actions.go", "picker.go", "prompt.go", "render.go", | |
| 1139 | - | "session.go", "session_back.go", | |
| 1139 | + | "session.go", | |
| 1140 | 1140 | } | |
| 1141 | 1141 | ||
| 1142 | 1142 | func TestProducerInvalidations(t *testing.T) { | |
| @@ -1248,14 +1248,6 @@ func TestProducerInvalidations(t *testing.T) { | |||
| 1248 | 1248 | require.NoError(t, d.closeTab(sess, tb, true)) | |
| 1249 | 1249 | }, | |
| 1250 | 1250 | }, | |
| 1251 | - | { | |
| 1252 | - | file: "session_back.go", | |
| 1253 | - | name: "back session fallback without a target", | |
| 1254 | - | run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) { | |
| 1255 | - | d.backSession(sess, ac) | |
| 1256 | - | require.Same(t, sess, ac.currentSession()) | |
| 1257 | - | }, | |
| 1258 | - | }, | |
| 1259 | 1251 | } | |
| 1260 | 1252 | ||
| 1261 | 1253 | exercised := make([]string, 0, len(cases)) | |
| @@ -1136,7 +1136,7 @@ func TestRenderCoordinatorResizeMetadata(t *testing.T) { | |||||
| 1136 | var producerFiles = []string{ | 1136 | var producerFiles = []string{ | ||
| 1137 | "attention.go", "client.go", "copymode.go", "floating.go", "input.go", | 1137 | "attention.go", "client.go", "copymode.go", "floating.go", "input.go", | ||
| 1138 | "palette.go", "pane_actions.go", "picker.go", "prompt.go", "render.go", | 1138 | "palette.go", "pane_actions.go", "picker.go", "prompt.go", "render.go", | ||
| 1139 | - | "session.go", "session_back.go", | 1139 | + | "session.go", |
| 1140 | } | 1140 | } | ||
| 1141 | 1141 | ||||
| 1142 | func TestProducerInvalidations(t *testing.T) { | 1142 | func TestProducerInvalidations(t *testing.T) { | ||
| @@ -1248,14 +1248,6 @@ func TestProducerInvalidations(t *testing.T) { | |||||
| 1248 | require.NoError(t, d.closeTab(sess, tb, true)) | 1248 | require.NoError(t, d.closeTab(sess, tb, true)) | ||
| 1249 | }, | 1249 | }, | ||
| 1250 | }, | 1250 | }, | ||
| 1251 | - | { | |||
| 1252 | - | file: "session_back.go", | |||
| 1253 | - | name: "back session fallback without a target", | |||
| 1254 | - | run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) { | |||
| 1255 | - | d.backSession(sess, ac) | |||
| 1256 | - | require.Same(t, sess, ac.currentSession()) | |||
| 1257 | - | }, | |||
| 1258 | - | }, | |||
| 1259 | } | 1251 | } | ||
| 1260 | 1252 | ||||
| 1261 | exercised := make([]string, 0, len(cases)) | 1253 | exercised := make([]string, 0, len(cases)) | ||
M internal/usecase/daemon/resume.go +0 -3
| @@ -301,7 +301,6 @@ func (d *Daemon) expireParked(token uint64, parked *parkedAttachment) { | |||
| 301 | 301 | // has verified d.parked[token] still points at parked when that matters. | |
| 302 | 302 | func (d *Daemon) removeParkedLocked(token uint64, parked *parkedAttachment) { | |
| 303 | 303 | delete(d.parked, token) | |
| 304 | - | parked.ac.clearPreviousSession() | |
| 305 | 304 | parked.ac.resumeToken = 0 | |
| 306 | 305 | parked.ac.parked = false | |
| 307 | 306 | if parked.timer != nil { | |
| @@ -402,7 +401,6 @@ type parkedAttachmentRetirement struct { | |||
| 402 | 401 | ||
| 403 | 402 | func (d *Daemon) retireParkedAttachmentLocked(token uint64, parked *parkedAttachment) parkedAttachmentRetirement { | |
| 404 | 403 | delete(d.parked, token) | |
| 405 | - | parked.ac.clearPreviousSession() | |
| 406 | 404 | parked.ac.resumeToken = 0 | |
| 407 | 405 | parked.ac.parked = false | |
| 408 | 406 | parked.ac.connectionGeneration.Add(1) | |
| @@ -538,7 +536,6 @@ func (d *Daemon) resumeLiveAttachment(h ports.Hello, tr ports.Transport, sz doma | |||
| 538 | 536 | // shutdown cannot leave an orphaned open transport with no owner. | |
| 539 | 537 | d.clearParkingInFlight(d.resumeTokenSnapshot(ac), ac) | |
| 540 | 538 | d.resetScreenDefaultColors(sess) | |
| 541 | - | ac.clearPreviousSession() | |
| 542 | 539 | _ = ac.closeCapturedTransport(ac.revokeTransport(oldSnap.transport)) | |
| 543 | 540 | d.mu.Lock() | |
| 544 | 541 | closing := d.closing | |
| @@ -301,7 +301,6 @@ func (d *Daemon) expireParked(token uint64, parked *parkedAttachment) { | |||||
| 301 | // has verified d.parked[token] still points at parked when that matters. | 301 | // has verified d.parked[token] still points at parked when that matters. | ||
| 302 | func (d *Daemon) removeParkedLocked(token uint64, parked *parkedAttachment) { | 302 | func (d *Daemon) removeParkedLocked(token uint64, parked *parkedAttachment) { | ||
| 303 | delete(d.parked, token) | 303 | delete(d.parked, token) | ||
| 304 | - | parked.ac.clearPreviousSession() | |||
| 305 | parked.ac.resumeToken = 0 | 304 | parked.ac.resumeToken = 0 | ||
| 306 | parked.ac.parked = false | 305 | parked.ac.parked = false | ||
| 307 | if parked.timer != nil { | 306 | if parked.timer != nil { | ||
| @@ -402,7 +401,6 @@ type parkedAttachmentRetirement struct { | |||||
| 402 | 401 | ||||
| 403 | func (d *Daemon) retireParkedAttachmentLocked(token uint64, parked *parkedAttachment) parkedAttachmentRetirement { | 402 | func (d *Daemon) retireParkedAttachmentLocked(token uint64, parked *parkedAttachment) parkedAttachmentRetirement { | ||
| 404 | delete(d.parked, token) | 403 | delete(d.parked, token) | ||
| 405 | - | parked.ac.clearPreviousSession() | |||
| 406 | parked.ac.resumeToken = 0 | 404 | parked.ac.resumeToken = 0 | ||
| 407 | parked.ac.parked = false | 405 | parked.ac.parked = false | ||
| 408 | parked.ac.connectionGeneration.Add(1) | 406 | parked.ac.connectionGeneration.Add(1) | ||
| @@ -538,7 +536,6 @@ func (d *Daemon) resumeLiveAttachment(h ports.Hello, tr ports.Transport, sz doma | |||||
| 538 | // shutdown cannot leave an orphaned open transport with no owner. | 536 | // shutdown cannot leave an orphaned open transport with no owner. | ||
| 539 | d.clearParkingInFlight(d.resumeTokenSnapshot(ac), ac) | 537 | d.clearParkingInFlight(d.resumeTokenSnapshot(ac), ac) | ||
| 540 | d.resetScreenDefaultColors(sess) | 538 | d.resetScreenDefaultColors(sess) | ||
| 541 | - | ac.clearPreviousSession() | |||
| 542 | _ = ac.closeCapturedTransport(ac.revokeTransport(oldSnap.transport)) | 539 | _ = ac.closeCapturedTransport(ac.revokeTransport(oldSnap.transport)) | ||
| 543 | d.mu.Lock() | 540 | d.mu.Lock() | ||
| 544 | closing := d.closing | 541 | closing := d.closing | ||
M internal/usecase/daemon/resume_test.go +2 -11
| @@ -1331,7 +1331,7 @@ func TestParkExpiryAndShutdownCleanup(t *testing.T) { | |||
| 1331 | 1331 | require.Zero(t, parked) | |
| 1332 | 1332 | } | |
| 1333 | 1333 | ||
| 1334 | - | func TestLiveParkAndResumeRetainsPreviousSession(t *testing.T) { | |
| 1334 | + | func TestLiveParkAndResumeRetainsAttachmentWithoutDaemonHistory(t *testing.T) { | |
| 1335 | 1335 | pty, release := newBlockingPTY(t) | |
| 1336 | 1336 | defer release() | |
| 1337 | 1337 | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| @@ -1339,11 +1339,6 @@ func TestLiveParkAndResumeRetainsPreviousSession(t *testing.T) { | |||
| 1339 | 1339 | sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr) | |
| 1340 | 1340 | require.NoError(t, err) | |
| 1341 | 1341 | ||
| 1342 | - | previous := &session{sessionCore: sessionCore{id: "previous"}} | |
| 1343 | - | d.mu.Lock() | |
| 1344 | - | d.sessions[previous.id] = previous | |
| 1345 | - | d.mu.Unlock() | |
| 1346 | - | ac.previousSession.Set(previous.id) | |
| 1347 | 1342 | d.clientGone(sess, ac, ac.transport(), false) | |
| 1348 | 1343 | ||
| 1349 | 1344 | token := ac.resumeToken | |
| @@ -1351,17 +1346,15 @@ func TestLiveParkAndResumeRetainsPreviousSession(t *testing.T) { | |||
| 1351 | 1346 | parked := d.parked[token] | |
| 1352 | 1347 | d.mu.Unlock() | |
| 1353 | 1348 | require.NotNil(t, parked) | |
| 1354 | - | require.Equal(t, previous.id, ac.previousSession.Get(), "a live parked attachment keeps its toggle") | |
| 1355 | 1349 | ||
| 1356 | 1350 | tr2, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{})) | |
| 1357 | 1351 | resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", token), tr2) | |
| 1358 | 1352 | require.NoError(t, err) | |
| 1359 | 1353 | require.Same(t, sess, resumedSess) | |
| 1360 | 1354 | require.Same(t, ac, resumedAC) | |
| 1361 | - | require.Equal(t, previous.id, resumedAC.previousSession.Get(), "resume keeps the live attachment toggle") | |
| 1362 | 1355 | } | |
| 1363 | 1356 | ||
| 1364 | - | func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) { | |
| 1357 | + | func TestDiscardingParkedAttachmentDoesNotOwnPreviousSession(t *testing.T) { | |
| 1365 | 1358 | for _, tc := range []struct { | |
| 1366 | 1359 | name string | |
| 1367 | 1360 | discard func(*Daemon, uint64, *parkedAttachment) []parkedAttachmentRetirement | |
| @@ -1388,7 +1381,6 @@ func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) { | |||
| 1388 | 1381 | sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr) | |
| 1389 | 1382 | require.NoError(t, err) | |
| 1390 | 1383 | ||
| 1391 | - | ac.previousSession.Set("previous") | |
| 1392 | 1384 | d.clientGone(sess, ac, ac.transport(), false) | |
| 1393 | 1385 | ||
| 1394 | 1386 | d.mu.Lock() | |
| @@ -1398,7 +1390,6 @@ func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) { | |||
| 1398 | 1390 | d.mu.Unlock() | |
| 1399 | 1391 | d.finishParkedAttachmentRetirements(retirements) | |
| 1400 | 1392 | ||
| 1401 | - | require.Empty(t, ac.previousSession.Get()) | |
| 1402 | 1393 | }) | |
| 1403 | 1394 | } | |
| 1404 | 1395 | } | |
| @@ -1331,7 +1331,7 @@ func TestParkExpiryAndShutdownCleanup(t *testing.T) { | |||||
| 1331 | require.Zero(t, parked) | 1331 | require.Zero(t, parked) | ||
| 1332 | } | 1332 | } | ||
| 1333 | 1333 | ||||
| 1334 | - | func TestLiveParkAndResumeRetainsPreviousSession(t *testing.T) { | 1334 | + | func TestLiveParkAndResumeRetainsAttachmentWithoutDaemonHistory(t *testing.T) { |
| 1335 | pty, release := newBlockingPTY(t) | 1335 | pty, release := newBlockingPTY(t) | ||
| 1336 | defer release() | 1336 | defer release() | ||
| 1337 | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | 1337 | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | ||
| @@ -1339,11 +1339,6 @@ func TestLiveParkAndResumeRetainsPreviousSession(t *testing.T) { | |||||
| 1339 | sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr) | 1339 | sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr) | ||
| 1340 | require.NoError(t, err) | 1340 | require.NoError(t, err) | ||
| 1341 | 1341 | ||||
| 1342 | - | previous := &session{sessionCore: sessionCore{id: "previous"}} | |||
| 1343 | - | d.mu.Lock() | |||
| 1344 | - | d.sessions[previous.id] = previous | |||
| 1345 | - | d.mu.Unlock() | |||
| 1346 | - | ac.previousSession.Set(previous.id) | |||
| 1347 | d.clientGone(sess, ac, ac.transport(), false) | 1342 | d.clientGone(sess, ac, ac.transport(), false) | ||
| 1348 | 1343 | ||||
| 1349 | token := ac.resumeToken | 1344 | token := ac.resumeToken | ||
| @@ -1351,17 +1346,15 @@ func TestLiveParkAndResumeRetainsPreviousSession(t *testing.T) { | |||||
| 1351 | parked := d.parked[token] | 1346 | parked := d.parked[token] | ||
| 1352 | d.mu.Unlock() | 1347 | d.mu.Unlock() | ||
| 1353 | require.NotNil(t, parked) | 1348 | require.NotNil(t, parked) | ||
| 1354 | - | require.Equal(t, previous.id, ac.previousSession.Get(), "a live parked attachment keeps its toggle") | |||
| 1355 | 1349 | ||||
| 1356 | tr2, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{})) | 1350 | tr2, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{})) | ||
| 1357 | resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", token), tr2) | 1351 | resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", token), tr2) | ||
| 1358 | require.NoError(t, err) | 1352 | require.NoError(t, err) | ||
| 1359 | require.Same(t, sess, resumedSess) | 1353 | require.Same(t, sess, resumedSess) | ||
| 1360 | require.Same(t, ac, resumedAC) | 1354 | require.Same(t, ac, resumedAC) | ||
| 1361 | - | require.Equal(t, previous.id, resumedAC.previousSession.Get(), "resume keeps the live attachment toggle") | |||
| 1362 | } | 1355 | } | ||
| 1363 | 1356 | ||||
| 1364 | - | func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) { | 1357 | + | func TestDiscardingParkedAttachmentDoesNotOwnPreviousSession(t *testing.T) { |
| 1365 | for _, tc := range []struct { | 1358 | for _, tc := range []struct { | ||
| 1366 | name string | 1359 | name string | ||
| 1367 | discard func(*Daemon, uint64, *parkedAttachment) []parkedAttachmentRetirement | 1360 | discard func(*Daemon, uint64, *parkedAttachment) []parkedAttachmentRetirement | ||
| @@ -1388,7 +1381,6 @@ func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) { | |||||
| 1388 | sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr) | 1381 | sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr) | ||
| 1389 | require.NoError(t, err) | 1382 | require.NoError(t, err) | ||
| 1390 | 1383 | ||||
| 1391 | - | ac.previousSession.Set("previous") | |||
| 1392 | d.clientGone(sess, ac, ac.transport(), false) | 1384 | d.clientGone(sess, ac, ac.transport(), false) | ||
| 1393 | 1385 | ||||
| 1394 | d.mu.Lock() | 1386 | d.mu.Lock() | ||
| @@ -1398,7 +1390,6 @@ func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) { | |||||
| 1398 | d.mu.Unlock() | 1390 | d.mu.Unlock() | ||
| 1399 | d.finishParkedAttachmentRetirements(retirements) | 1391 | d.finishParkedAttachmentRetirements(retirements) | ||
| 1400 | 1392 | ||||
| 1401 | - | require.Empty(t, ac.previousSession.Get()) | |||
| 1402 | }) | 1393 | }) | ||
| 1403 | } | 1394 | } | ||
| 1404 | } | 1395 | } | ||
M internal/usecase/daemon/session.go +0 -5
| @@ -460,7 +460,6 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name | |||
| 460 | 460 | } | |
| 461 | 461 | ||
| 462 | 462 | d.touchMRU(newSess) | |
| 463 | - | ac.recordPreviousSession(from) | |
| 464 | 463 | d.log.Info("client attached", "session", newSess.name, "resume", ac.resumeCapable) | |
| 465 | 464 | d.deferAttachmentTransitionCleanups(transition) | |
| 466 | 465 | d.firstPaintForTransition(transition.published) | |
| @@ -515,9 +514,6 @@ func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionT | |||
| 515 | 514 | } | |
| 516 | 515 | ||
| 517 | 516 | d.touchMRU(created) | |
| 518 | - | if token.sess != nil { | |
| 519 | - | token.ac.recordPreviousSession(token.sess) | |
| 520 | - | } | |
| 521 | 517 | d.log.Info("client attached", "session", created.name, "resume", token.ac.resumeCapable) | |
| 522 | 518 | d.deferAttachmentTransitionCleanups(transition) | |
| 523 | 519 | d.firstPaintForTransition(transition.published) | |
| @@ -1614,7 +1610,6 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu | |||
| 1614 | 1610 | } | |
| 1615 | 1611 | for _, attachment := range attachments { | |
| 1616 | 1612 | d.unregisterPreview(attachment.ac) | |
| 1617 | - | attachment.ac.clearPreviousSession() | |
| 1618 | 1613 | attachment.ac.clearCaptureFrames() | |
| 1619 | 1614 | } | |
| 1620 | 1615 | ||
| @@ -460,7 +460,6 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name | |||||
| 460 | } | 460 | } | ||
| 461 | 461 | ||||
| 462 | d.touchMRU(newSess) | 462 | d.touchMRU(newSess) | ||
| 463 | - | ac.recordPreviousSession(from) | |||
| 464 | d.log.Info("client attached", "session", newSess.name, "resume", ac.resumeCapable) | 463 | d.log.Info("client attached", "session", newSess.name, "resume", ac.resumeCapable) | ||
| 465 | d.deferAttachmentTransitionCleanups(transition) | 464 | d.deferAttachmentTransitionCleanups(transition) | ||
| 466 | d.firstPaintForTransition(transition.published) | 465 | d.firstPaintForTransition(transition.published) | ||
| @@ -515,9 +514,6 @@ func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionT | |||||
| 515 | } | 514 | } | ||
| 516 | 515 | ||||
| 517 | d.touchMRU(created) | 516 | d.touchMRU(created) | ||
| 518 | - | if token.sess != nil { | |||
| 519 | - | token.ac.recordPreviousSession(token.sess) | |||
| 520 | - | } | |||
| 521 | d.log.Info("client attached", "session", created.name, "resume", token.ac.resumeCapable) | 517 | d.log.Info("client attached", "session", created.name, "resume", token.ac.resumeCapable) | ||
| 522 | d.deferAttachmentTransitionCleanups(transition) | 518 | d.deferAttachmentTransitionCleanups(transition) | ||
| 523 | d.firstPaintForTransition(transition.published) | 519 | d.firstPaintForTransition(transition.published) | ||
| @@ -1614,7 +1610,6 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu | |||||
| 1614 | } | 1610 | } | ||
| 1615 | for _, attachment := range attachments { | 1611 | for _, attachment := range attachments { | ||
| 1616 | d.unregisterPreview(attachment.ac) | 1612 | d.unregisterPreview(attachment.ac) | ||
| 1617 | - | attachment.ac.clearPreviousSession() | |||
| 1618 | attachment.ac.clearCaptureFrames() | 1613 | attachment.ac.clearCaptureFrames() | ||
| 1619 | } | 1614 | } | ||
| 1620 | 1615 | ||||
M internal/usecase/daemon/session_back.go +11 -38
| @@ -2,36 +2,31 @@ package daemon | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "github.com/bnema/vev/internal/domain" | |
| 5 | - | "github.com/bnema/vev/internal/usecase/picker" | |
| 5 | + | "github.com/bnema/vev/internal/ports" | |
| 6 | 6 | ) | |
| 7 | 7 | ||
| 8 | - | // backSession toggles this attachment between its current session and the | |
| 9 | - | // immediately preceding successfully activated session. | |
| 8 | + | // backSessionForAttachment delegates previous-route ownership to the client | |
| 9 | + | // once it has published a complete route snapshot. There is no daemon-local | |
| 10 | + | // previous-session fallback after the global-history cutover. | |
| 10 | 11 | func (d *Daemon) backSessionForAttachment(token attachmentConnectionToken) error { | |
| 11 | 12 | if d == nil || token.sess == nil || token.ac == nil { | |
| 12 | 13 | return nil | |
| 13 | 14 | } | |
| 14 | - | targetID := token.ac.previousSession.Get() | |
| 15 | - | target := d.sessionByID(targetID) | |
| 16 | - | if target == nil || target == token.sess { | |
| 17 | - | token.ac.clearPreviousSessionIf(targetID) | |
| 15 | + | snapshot := token.ac.routeSnapshotCopy() | |
| 16 | + | if snapshot.Generation == 0 || snapshot.Previous.Key == 0 || snapshot.Previous.Generation == 0 { | |
| 18 | 17 | return nil | |
| 19 | 18 | } | |
| 20 | - | pickerTarget := picker.Target{Session: targetID, TabIndex: -1} | |
| 21 | - | return d.switchToTargetForAttachment(token, pickerTarget, sessionHandoffGuard{}, "back-session") | |
| 19 | + | return d.sendRecentRouteNavigationActionForAttachment(token, ports.RouteNavigationAction{ | |
| 20 | + | SnapshotGeneration: snapshot.Generation, | |
| 21 | + | Key: snapshot.Previous.Key, | |
| 22 | + | Generation: snapshot.Previous.Generation, | |
| 23 | + | }) | |
| 22 | 24 | } | |
| 23 | 25 | ||
| 24 | 26 | func (d *Daemon) backSession(current *session, ac *attachedClient) { | |
| 25 | 27 | if d == nil || current == nil || ac == nil { | |
| 26 | 28 | return | |
| 27 | 29 | } | |
| 28 | - | targetID := ac.previousSession.Get() | |
| 29 | - | target := d.sessionByID(targetID) | |
| 30 | - | if target == nil || target == current { | |
| 31 | - | ac.clearPreviousSessionIf(targetID) | |
| 32 | - | d.invalidateRender(current, ac, true, "session_back.go") | |
| 33 | - | return | |
| 34 | - | } | |
| 35 | 30 | token := attachmentToken(current, ac, ac.transport()) | |
| 36 | 31 | effect, admitted := ac.beginAttachmentEffect(token) | |
| 37 | 32 | if !admitted { | |
| @@ -44,25 +39,3 @@ func (d *Daemon) backSession(current *session, ac *attachedClient) { | |||
| 44 | 39 | d.reportError(current, err) | |
| 45 | 40 | } | |
| 46 | 41 | } | |
| 47 | - | ||
| 48 | - | // clearPreviousSessionIf clears target only if it has not been replaced since | |
| 49 | - | // the caller observed it. This preserves a concurrent successful hand-off. | |
| 50 | - | func (ac *attachedClient) clearPreviousSessionIf(targetID domain.SessionID) { | |
| 51 | - | if ac == nil { | |
| 52 | - | return | |
| 53 | - | } | |
| 54 | - | ac.previousSession.With(func(previous *domain.SessionID) { | |
| 55 | - | if *previous == targetID { | |
| 56 | - | *previous = "" | |
| 57 | - | } | |
| 58 | - | }) | |
| 59 | - | } | |
| 60 | - | ||
| 61 | - | // recordPreviousSession is called only after a cross-session hand-off has | |
| 62 | - | // committed. Keeping this after the commit makes failed/displaced switches | |
| 63 | - | // leave the toggle pair intact. | |
| 64 | - | func (ac *attachedClient) recordPreviousSession(origin *session) { | |
| 65 | - | if ac != nil && origin != nil { | |
| 66 | - | ac.previousSession.Set(origin.id) | |
| 67 | - | } | |
| 68 | - | } | |
| @@ -2,36 +2,31 @@ package daemon | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "github.com/bnema/vev/internal/domain" | 4 | "github.com/bnema/vev/internal/domain" | ||
| 5 | - | "github.com/bnema/vev/internal/usecase/picker" | 5 | + | "github.com/bnema/vev/internal/ports" |
| 6 | ) | 6 | ) | ||
| 7 | 7 | ||||
| 8 | - | // backSession toggles this attachment between its current session and the | 8 | + | // backSessionForAttachment delegates previous-route ownership to the client |
| 9 | - | // immediately preceding successfully activated session. | 9 | + | // once it has published a complete route snapshot. There is no daemon-local |
| 10 | + | // previous-session fallback after the global-history cutover. | |||
| 10 | func (d *Daemon) backSessionForAttachment(token attachmentConnectionToken) error { | 11 | func (d *Daemon) backSessionForAttachment(token attachmentConnectionToken) error { | ||
| 11 | if d == nil || token.sess == nil || token.ac == nil { | 12 | if d == nil || token.sess == nil || token.ac == nil { | ||
| 12 | return nil | 13 | return nil | ||
| 13 | } | 14 | } | ||
| 14 | - | targetID := token.ac.previousSession.Get() | 15 | + | snapshot := token.ac.routeSnapshotCopy() |
| 15 | - | target := d.sessionByID(targetID) | 16 | + | if snapshot.Generation == 0 || snapshot.Previous.Key == 0 || snapshot.Previous.Generation == 0 { |
| 16 | - | if target == nil || target == token.sess { | |||
| 17 | - | token.ac.clearPreviousSessionIf(targetID) | |||
| 18 | return nil | 17 | return nil | ||
| 19 | } | 18 | } | ||
| 20 | - | pickerTarget := picker.Target{Session: targetID, TabIndex: -1} | 19 | + | return d.sendRecentRouteNavigationActionForAttachment(token, ports.RouteNavigationAction{ |
| 21 | - | return d.switchToTargetForAttachment(token, pickerTarget, sessionHandoffGuard{}, "back-session") | 20 | + | SnapshotGeneration: snapshot.Generation, |
| 21 | + | Key: snapshot.Previous.Key, | |||
| 22 | + | Generation: snapshot.Previous.Generation, | |||
| 23 | + | }) | |||
| 22 | } | 24 | } | ||
| 23 | 25 | ||||
| 24 | func (d *Daemon) backSession(current *session, ac *attachedClient) { | 26 | func (d *Daemon) backSession(current *session, ac *attachedClient) { | ||
| 25 | if d == nil || current == nil || ac == nil { | 27 | if d == nil || current == nil || ac == nil { | ||
| 26 | return | 28 | return | ||
| 27 | } | 29 | } | ||
| 28 | - | targetID := ac.previousSession.Get() | |||
| 29 | - | target := d.sessionByID(targetID) | |||
| 30 | - | if target == nil || target == current { | |||
| 31 | - | ac.clearPreviousSessionIf(targetID) | |||
| 32 | - | d.invalidateRender(current, ac, true, "session_back.go") | |||
| 33 | - | return | |||
| 34 | - | } | |||
| 35 | token := attachmentToken(current, ac, ac.transport()) | 30 | token := attachmentToken(current, ac, ac.transport()) | ||
| 36 | effect, admitted := ac.beginAttachmentEffect(token) | 31 | effect, admitted := ac.beginAttachmentEffect(token) | ||
| 37 | if !admitted { | 32 | if !admitted { | ||
| @@ -44,25 +39,3 @@ func (d *Daemon) backSession(current *session, ac *attachedClient) { | |||||
| 44 | d.reportError(current, err) | 39 | d.reportError(current, err) | ||
| 45 | } | 40 | } | ||
| 46 | } | 41 | } | ||
| 47 | - | ||||
| 48 | - | // clearPreviousSessionIf clears target only if it has not been replaced since | |||
| 49 | - | // the caller observed it. This preserves a concurrent successful hand-off. | |||
| 50 | - | func (ac *attachedClient) clearPreviousSessionIf(targetID domain.SessionID) { | |||
| 51 | - | if ac == nil { | |||
| 52 | - | return | |||
| 53 | - | } | |||
| 54 | - | ac.previousSession.With(func(previous *domain.SessionID) { | |||
| 55 | - | if *previous == targetID { | |||
| 56 | - | *previous = "" | |||
| 57 | - | } | |||
| 58 | - | }) | |||
| 59 | - | } | |||
| 60 | - | ||||
| 61 | - | // recordPreviousSession is called only after a cross-session hand-off has | |||
| 62 | - | // committed. Keeping this after the commit makes failed/displaced switches | |||
| 63 | - | // leave the toggle pair intact. | |||
| 64 | - | func (ac *attachedClient) recordPreviousSession(origin *session) { | |||
| 65 | - | if ac != nil && origin != nil { | |||
| 66 | - | ac.previousSession.Set(origin.id) | |||
| 67 | - | } | |||
| 68 | - | } | |||
A internal/usecase/daemon/session_back_route_test.go +47 -0
| @@ -0,0 +1,47 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/ports" | |
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestBackSessionUsesClientPreviousRouteAfterSnapshotPublication(t *testing.T) { | |
| 11 | + | d, source, ac, _ := newManualSessionWithPTYs(t, nil) | |
| 12 | + | transport := &closeTrackingTransport{} | |
| 13 | + | ac.replaceTransport(transport) | |
| 14 | + | rc := d.attachCoordinator(source, nil, ac, true) | |
| 15 | + | token := source.attachmentToken(ac, transport) | |
| 16 | + | token.lease = rc.attachmentLease(ac) | |
| 17 | + | ac.publishAttachmentCapability(token) | |
| 18 | + | effect, admitted := ac.beginAttachmentEffect(token) | |
| 19 | + | require.True(t, admitted) | |
| 20 | + | defer effect.End() | |
| 21 | + | token.effect = effect | |
| 22 | + | ac.setRouteSnapshot(ports.RecentRouteSnapshot{ | |
| 23 | + | Generation: 5, | |
| 24 | + | Active: ports.RouteRef{Key: 9, Generation: 5}, | |
| 25 | + | Previous: ports.RouteRef{Key: 7, Generation: 3}, | |
| 26 | + | Entries: []ports.RecentRouteEntry{{ | |
| 27 | + | Key: 7, Generation: 3, Name: "previous", Kind: ports.RouteKindLocal, | |
| 28 | + | }}, | |
| 29 | + | }) | |
| 30 | + | ||
| 31 | + | require.NoError(t, d.backSessionForAttachment(token)) | |
| 32 | + | frames := transport.Sends() | |
| 33 | + | require.NotEmpty(t, frames) | |
| 34 | + | var action ports.RouteNavigationAction | |
| 35 | + | found := false | |
| 36 | + | for _, frame := range frames { | |
| 37 | + | if frame.Type != ports.MsgNavigateRecentRoute { | |
| 38 | + | continue | |
| 39 | + | } | |
| 40 | + | var err error | |
| 41 | + | action, err = ports.UnmarshalRouteNavigationAction(frame.Payload) | |
| 42 | + | require.NoError(t, err) | |
| 43 | + | found = true | |
| 44 | + | } | |
| 45 | + | require.True(t, found) | |
| 46 | + | require.Equal(t, ports.RouteNavigationAction{SnapshotGeneration: 5, Key: 7, Generation: 3}, action) | |
| 47 | + | } | |
| @@ -0,0 +1,47 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/ports" | |||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestBackSessionUsesClientPreviousRouteAfterSnapshotPublication(t *testing.T) { | |||
| 11 | + | d, source, ac, _ := newManualSessionWithPTYs(t, nil) | |||
| 12 | + | transport := &closeTrackingTransport{} | |||
| 13 | + | ac.replaceTransport(transport) | |||
| 14 | + | rc := d.attachCoordinator(source, nil, ac, true) | |||
| 15 | + | token := source.attachmentToken(ac, transport) | |||
| 16 | + | token.lease = rc.attachmentLease(ac) | |||
| 17 | + | ac.publishAttachmentCapability(token) | |||
| 18 | + | effect, admitted := ac.beginAttachmentEffect(token) | |||
| 19 | + | require.True(t, admitted) | |||
| 20 | + | defer effect.End() | |||
| 21 | + | token.effect = effect | |||
| 22 | + | ac.setRouteSnapshot(ports.RecentRouteSnapshot{ | |||
| 23 | + | Generation: 5, | |||
| 24 | + | Active: ports.RouteRef{Key: 9, Generation: 5}, | |||
| 25 | + | Previous: ports.RouteRef{Key: 7, Generation: 3}, | |||
| 26 | + | Entries: []ports.RecentRouteEntry{{ | |||
| 27 | + | Key: 7, Generation: 3, Name: "previous", Kind: ports.RouteKindLocal, | |||
| 28 | + | }}, | |||
| 29 | + | }) | |||
| 30 | + | ||||
| 31 | + | require.NoError(t, d.backSessionForAttachment(token)) | |||
| 32 | + | frames := transport.Sends() | |||
| 33 | + | require.NotEmpty(t, frames) | |||
| 34 | + | var action ports.RouteNavigationAction | |||
| 35 | + | found := false | |||
| 36 | + | for _, frame := range frames { | |||
| 37 | + | if frame.Type != ports.MsgNavigateRecentRoute { | |||
| 38 | + | continue | |||
| 39 | + | } | |||
| 40 | + | var err error | |||
| 41 | + | action, err = ports.UnmarshalRouteNavigationAction(frame.Payload) | |||
| 42 | + | require.NoError(t, err) | |||
| 43 | + | found = true | |||
| 44 | + | } | |||
| 45 | + | require.True(t, found) | |||
| 46 | + | require.Equal(t, ports.RouteNavigationAction{SnapshotGeneration: 5, Key: 7, Generation: 3}, action) | |||
| 47 | + | } | |||
M internal/usecase/daemon/session_view.go +3 -3
| @@ -24,9 +24,9 @@ type tabView struct { | |||
| 24 | 24 | } | |
| 25 | 25 | ||
| 26 | 26 | // sessionView is an immutable, value-only description of a session. It | |
| 27 | - | // deliberately retains no live pointers, mirroring recentSession | |
| 28 | - | // (recent_sessions.go). Listing paths (picker, MRU bar, palette, and | |
| 29 | - | // list-sessions) read local session state through snapshotView. | |
| 27 | + | // deliberately retains no live pointers, mirroring route snapshot entries. | |
| 28 | + | // Listing paths (picker, palette, and list-sessions) read local session state | |
| 29 | + | // through snapshotView. | |
| 30 | 30 | type sessionView struct { | |
| 31 | 31 | id domain.SessionID | |
| 32 | 32 | incarnation domain.IncarnationID | |
| @@ -24,9 +24,9 @@ type tabView struct { | |||||
| 24 | } | 24 | } | ||
| 25 | 25 | ||||
| 26 | // sessionView is an immutable, value-only description of a session. It | 26 | // sessionView is an immutable, value-only description of a session. It | ||
| 27 | - | // deliberately retains no live pointers, mirroring recentSession | 27 | + | // deliberately retains no live pointers, mirroring route snapshot entries. |
| 28 | - | // (recent_sessions.go). Listing paths (picker, MRU bar, palette, and | 28 | + | // Listing paths (picker, palette, and list-sessions) read local session state |
| 29 | - | // list-sessions) read local session state through snapshotView. | 29 | + | // through snapshotView. |
| 30 | type sessionView struct { | 30 | type sessionView struct { | ||
| 31 | id domain.SessionID | 31 | id domain.SessionID | ||
| 32 | incarnation domain.IncarnationID | 32 | incarnation domain.IncarnationID | ||
M internal/usecase/daemon/status.go +23 -23
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "time" | |
| 8 | 8 | ||
| 9 | 9 | "github.com/bnema/vev/internal/domain" | |
| 10 | + | "github.com/bnema/vev/internal/ports" | |
| 10 | 11 | "github.com/bnema/vev/internal/usecase/command" | |
| 11 | 12 | "github.com/bnema/vev/internal/usecase/palette" | |
| 12 | 13 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| @@ -224,18 +225,20 @@ func tabDisplayName(tb *tab, index int) string { | |||
| 224 | 225 | return strconv.Itoa(index + 1) | |
| 225 | 226 | } | |
| 226 | 227 | ||
| 227 | - | func rankedRecentForHints(hints *palette.ContextualHints, recent []recentSession) []rankedRecent { | |
| 228 | + | func rankedRecentForHintsWithSnapshot(hints *palette.ContextualHints, snapshot ports.RecentRouteSnapshot) []rankedRecent { | |
| 228 | 229 | if hints == nil || hints.Kind != command.ContextHintRecentSessions { | |
| 229 | 230 | return nil | |
| 230 | 231 | } | |
| 231 | - | formatted := formatRecentRoutePresentations(recentRoutePresentations(recent)) | |
| 232 | + | var formatted []recentRouteDisplay | |
| 233 | + | if snapshot.Generation != 0 { | |
| 234 | + | formatted = formatRecentRouteSnapshot(snapshot) | |
| 235 | + | } | |
| 232 | 236 | entries := make([]rankedRecent, 0, len(hints.Recent)) | |
| 233 | 237 | for i, hint := range hints.Recent { | |
| 234 | 238 | display := recentRouteDisplay{name: hint.Name} | |
| 235 | - | // recent was copied with the hint under paletteMu, so this only enriches | |
| 236 | - | // the render snapshot and never performs a live domain lookup. The | |
| 237 | - | // formatted capture is authoritative when available, keeping ranked | |
| 238 | - | // JRS labels identical to normal MRU labels. | |
| 239 | + | // The hint and route snapshot are immutable interaction data. The | |
| 240 | + | // formatted snapshot is authoritative when available, keeping ranked | |
| 241 | + | // JRS labels identical to normal MRU labels without a live lookup. | |
| 239 | 242 | if i < len(formatted) { | |
| 240 | 243 | display = formatted[i] | |
| 241 | 244 | } | |
| @@ -252,25 +255,24 @@ func rankedRecentForHints(hints *palette.ContextualHints, recent []recentSession | |||
| 252 | 255 | } | |
| 253 | 256 | ||
| 254 | 257 | // barStateForPaletteHints selects snapshot-only contextual composition when a | |
| 255 | - | // palette interaction has captured recent-session hints. Normal palette state | |
| 256 | - | // continues to use the canonical, live MRU list. | |
| 257 | - | func (d *Daemon) barStateForPaletteHints(cur *session, statusFeedback string, hints *palette.ContextualHints, recent []recentSession) barState { | |
| 258 | - | var entry *session | |
| 259 | - | if cur != nil { | |
| 260 | - | entry = cur | |
| 261 | - | } | |
| 262 | - | return d.barStateForAttachmentPaletteHints(entry, statusFeedback, hints, recent) | |
| 258 | + | // palette interaction has captured recent-route hints. | |
| 259 | + | func (d *Daemon) barStateForPaletteHints(cur *session, statusFeedback string, hints *palette.ContextualHints) barState { | |
| 260 | + | return d.barStateForAttachmentPaletteHints(cur, statusFeedback, hints) | |
| 263 | 261 | } | |
| 264 | 262 | ||
| 265 | 263 | // barStateForAttachmentPaletteHints composes daemon-owned chrome for an | |
| 266 | 264 | // attachment. Bar scripts remain local-session-only because | |
| 267 | 265 | // their existing execution contract depends on local tabs and PTYs. | |
| 268 | - | func (d *Daemon) barStateForAttachmentPaletteHints(cur *session, statusFeedback string, hints *palette.ContextualHints, recent []recentSession) barState { | |
| 269 | - | return d.barStateForAttachmentPaletteHintsFor(cur, nil, statusFeedback, hints, recent) | |
| 266 | + | func (d *Daemon) barStateForAttachmentPaletteHints(cur *session, statusFeedback string, hints *palette.ContextualHints) barState { | |
| 267 | + | return d.barStateForAttachmentPaletteHintsFor(cur, nil, statusFeedback, hints, ports.RecentRouteSnapshot{}) | |
| 270 | 268 | } | |
| 271 | 269 | ||
| 272 | - | func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur *session, ac *attachedClient, statusFeedback string, hints *palette.ContextualHints, recent []recentSession) barState { | |
| 273 | - | ranked := rankedRecentForHints(hints, recent) | |
| 270 | + | func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur *session, ac *attachedClient, statusFeedback string, hints *palette.ContextualHints, capturedRouteSnapshot ports.RecentRouteSnapshot) barState { | |
| 271 | + | routeSnapshot := capturedRouteSnapshot | |
| 272 | + | if routeSnapshot.Generation == 0 && ac != nil { | |
| 273 | + | routeSnapshot = ac.routeSnapshotCopy() | |
| 274 | + | } | |
| 275 | + | ranked := rankedRecentForHintsWithSnapshot(hints, routeSnapshot) | |
| 274 | 276 | state := barState{statusFeedback: statusFeedback} | |
| 275 | 277 | if d != nil { | |
| 276 | 278 | state.attentionFrame = d.attentionFrame() | |
| @@ -287,16 +289,14 @@ func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur *session, ac *attached | |||
| 287 | 289 | } | |
| 288 | 290 | if ranked != nil { | |
| 289 | 291 | state.rankedRecent = ranked | |
| 290 | - | } else if d != nil { | |
| 291 | - | state.mru = formatRecentRoutePresentations(recentRoutePresentations(d.recentSessions(cur))) | |
| 292 | + | } else if routeSnapshot.Generation != 0 { | |
| 293 | + | state.mru = formatRecentRouteSnapshot(routeSnapshot) | |
| 292 | 294 | } | |
| 293 | 295 | return state | |
| 294 | 296 | } | |
| 295 | 297 | ||
| 296 | 298 | func (d *Daemon) barStateFor(cur *session, statusFeedback string) barState { | |
| 297 | - | state := d.barStateBase(cur, statusFeedback) | |
| 298 | - | state.mru = formatRecentRoutePresentations(recentRoutePresentations(d.recentSessions(cur))) | |
| 299 | - | return state | |
| 299 | + | return d.barStateBase(cur, statusFeedback) | |
| 300 | 300 | } | |
| 301 | 301 | ||
| 302 | 302 | func (d *Daemon) barStateBase(cur *session, statusFeedback string) barState { | |
| @@ -7,6 +7,7 @@ import ( | |||||
| 7 | "time" | 7 | "time" | ||
| 8 | 8 | ||||
| 9 | "github.com/bnema/vev/internal/domain" | 9 | "github.com/bnema/vev/internal/domain" | ||
| 10 | + | "github.com/bnema/vev/internal/ports" | |||
| 10 | "github.com/bnema/vev/internal/usecase/command" | 11 | "github.com/bnema/vev/internal/usecase/command" | ||
| 11 | "github.com/bnema/vev/internal/usecase/palette" | 12 | "github.com/bnema/vev/internal/usecase/palette" | ||
| 12 | themeui "github.com/bnema/vev/internal/usecase/theme" | 13 | themeui "github.com/bnema/vev/internal/usecase/theme" | ||
| @@ -224,18 +225,20 @@ func tabDisplayName(tb *tab, index int) string { | |||||
| 224 | return strconv.Itoa(index + 1) | 225 | return strconv.Itoa(index + 1) | ||
| 225 | } | 226 | } | ||
| 226 | 227 | ||||
| 227 | - | func rankedRecentForHints(hints *palette.ContextualHints, recent []recentSession) []rankedRecent { | 228 | + | func rankedRecentForHintsWithSnapshot(hints *palette.ContextualHints, snapshot ports.RecentRouteSnapshot) []rankedRecent { |
| 228 | if hints == nil || hints.Kind != command.ContextHintRecentSessions { | 229 | if hints == nil || hints.Kind != command.ContextHintRecentSessions { | ||
| 229 | return nil | 230 | return nil | ||
| 230 | } | 231 | } | ||
| 231 | - | formatted := formatRecentRoutePresentations(recentRoutePresentations(recent)) | 232 | + | var formatted []recentRouteDisplay |
| 233 | + | if snapshot.Generation != 0 { | |||
| 234 | + | formatted = formatRecentRouteSnapshot(snapshot) | |||
| 235 | + | } | |||
| 232 | entries := make([]rankedRecent, 0, len(hints.Recent)) | 236 | entries := make([]rankedRecent, 0, len(hints.Recent)) | ||
| 233 | for i, hint := range hints.Recent { | 237 | for i, hint := range hints.Recent { | ||
| 234 | display := recentRouteDisplay{name: hint.Name} | 238 | display := recentRouteDisplay{name: hint.Name} | ||
| 235 | - | // recent was copied with the hint under paletteMu, so this only enriches | 239 | + | // The hint and route snapshot are immutable interaction data. The |
| 236 | - | // the render snapshot and never performs a live domain lookup. The | 240 | + | // formatted snapshot is authoritative when available, keeping ranked |
| 237 | - | // formatted capture is authoritative when available, keeping ranked | 241 | + | // JRS labels identical to normal MRU labels without a live lookup. |
| 238 | - | // JRS labels identical to normal MRU labels. | |||
| 239 | if i < len(formatted) { | 242 | if i < len(formatted) { | ||
| 240 | display = formatted[i] | 243 | display = formatted[i] | ||
| 241 | } | 244 | } | ||
| @@ -252,25 +255,24 @@ func rankedRecentForHints(hints *palette.ContextualHints, recent []recentSession | |||||
| 252 | } | 255 | } | ||
| 253 | 256 | ||||
| 254 | // barStateForPaletteHints selects snapshot-only contextual composition when a | 257 | // barStateForPaletteHints selects snapshot-only contextual composition when a | ||
| 255 | - | // palette interaction has captured recent-session hints. Normal palette state | 258 | + | // palette interaction has captured recent-route hints. |
| 256 | - | // continues to use the canonical, live MRU list. | 259 | + | func (d *Daemon) barStateForPaletteHints(cur *session, statusFeedback string, hints *palette.ContextualHints) barState { |
| 257 | - | func (d *Daemon) barStateForPaletteHints(cur *session, statusFeedback string, hints *palette.ContextualHints, recent []recentSession) barState { | 260 | + | return d.barStateForAttachmentPaletteHints(cur, statusFeedback, hints) |
| 258 | - | var entry *session | |||
| 259 | - | if cur != nil { | |||
| 260 | - | entry = cur | |||
| 261 | - | } | |||
| 262 | - | return d.barStateForAttachmentPaletteHints(entry, statusFeedback, hints, recent) | |||
| 263 | } | 261 | } | ||
| 264 | 262 | ||||
| 265 | // barStateForAttachmentPaletteHints composes daemon-owned chrome for an | 263 | // barStateForAttachmentPaletteHints composes daemon-owned chrome for an | ||
| 266 | // attachment. Bar scripts remain local-session-only because | 264 | // attachment. Bar scripts remain local-session-only because | ||
| 267 | // their existing execution contract depends on local tabs and PTYs. | 265 | // their existing execution contract depends on local tabs and PTYs. | ||
| 268 | - | func (d *Daemon) barStateForAttachmentPaletteHints(cur *session, statusFeedback string, hints *palette.ContextualHints, recent []recentSession) barState { | 266 | + | func (d *Daemon) barStateForAttachmentPaletteHints(cur *session, statusFeedback string, hints *palette.ContextualHints) barState { |
| 269 | - | return d.barStateForAttachmentPaletteHintsFor(cur, nil, statusFeedback, hints, recent) | 267 | + | return d.barStateForAttachmentPaletteHintsFor(cur, nil, statusFeedback, hints, ports.RecentRouteSnapshot{}) |
| 270 | } | 268 | } | ||
| 271 | 269 | ||||
| 272 | - | func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur *session, ac *attachedClient, statusFeedback string, hints *palette.ContextualHints, recent []recentSession) barState { | 270 | + | func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur *session, ac *attachedClient, statusFeedback string, hints *palette.ContextualHints, capturedRouteSnapshot ports.RecentRouteSnapshot) barState { |
| 273 | - | ranked := rankedRecentForHints(hints, recent) | 271 | + | routeSnapshot := capturedRouteSnapshot |
| 272 | + | if routeSnapshot.Generation == 0 && ac != nil { | |||
| 273 | + | routeSnapshot = ac.routeSnapshotCopy() | |||
| 274 | + | } | |||
| 275 | + | ranked := rankedRecentForHintsWithSnapshot(hints, routeSnapshot) | |||
| 274 | state := barState{statusFeedback: statusFeedback} | 276 | state := barState{statusFeedback: statusFeedback} | ||
| 275 | if d != nil { | 277 | if d != nil { | ||
| 276 | state.attentionFrame = d.attentionFrame() | 278 | state.attentionFrame = d.attentionFrame() | ||
| @@ -287,16 +289,14 @@ func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur *session, ac *attached | |||||
| 287 | } | 289 | } | ||
| 288 | if ranked != nil { | 290 | if ranked != nil { | ||
| 289 | state.rankedRecent = ranked | 291 | state.rankedRecent = ranked | ||
| 290 | - | } else if d != nil { | 292 | + | } else if routeSnapshot.Generation != 0 { |
| 291 | - | state.mru = formatRecentRoutePresentations(recentRoutePresentations(d.recentSessions(cur))) | 293 | + | state.mru = formatRecentRouteSnapshot(routeSnapshot) |
| 292 | } | 294 | } | ||
| 293 | return state | 295 | return state | ||
| 294 | } | 296 | } | ||
| 295 | 297 | ||||
| 296 | func (d *Daemon) barStateFor(cur *session, statusFeedback string) barState { | 298 | func (d *Daemon) barStateFor(cur *session, statusFeedback string) barState { | ||
| 297 | - | state := d.barStateBase(cur, statusFeedback) | 299 | + | return d.barStateBase(cur, statusFeedback) |
| 298 | - | state.mru = formatRecentRoutePresentations(recentRoutePresentations(d.recentSessions(cur))) | |||
| 299 | - | return state | |||
| 300 | } | 300 | } | ||
| 301 | 301 | ||||
| 302 | func (d *Daemon) barStateBase(cur *session, statusFeedback string) barState { | 302 | func (d *Daemon) barStateBase(cur *session, statusFeedback string) barState { | ||
M internal/usecase/daemon/status_test.go +10 -91
| @@ -968,32 +968,33 @@ func TestBarStateForPaletteHintsNormalizesTypedNilSession(t *testing.T) { | |||
| 968 | 968 | var sess *session | |
| 969 | 969 | ||
| 970 | 970 | require.NotPanics(t, func() { | |
| 971 | - | state := d.barStateForPaletteHints(sess, "feedback", nil, nil) | |
| 971 | + | state := d.barStateForPaletteHints(sess, "feedback", nil) | |
| 972 | 972 | require.Equal(t, "feedback", state.statusFeedback) | |
| 973 | 973 | require.Empty(t, state.status.session) | |
| 974 | 974 | }) | |
| 975 | 975 | } | |
| 976 | 976 | ||
| 977 | - | func TestBarStateForContextualRecentUsesSnapshotAndNormalUsesLiveMRU(t *testing.T) { | |
| 977 | + | func TestBarStateForContextualRecentUsesClientSnapshot(t *testing.T) { | |
| 978 | 978 | p, releasePTY := newBlockingPTY(t) | |
| 979 | - | d, sess, _, _ := newManualSessionWithPTYs(t, p) | |
| 979 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 980 | 980 | defer releasePTY() | |
| 981 | - | live := &session{sessionCore: sessionCore{id: "live", name: "live"}, tabs: []*tab{{}}} | |
| 982 | - | live.mruAt.Store(1) | |
| 983 | - | d.sessions[live.id] = live | |
| 984 | 981 | ||
| 982 | + | snapshot := ports.RecentRouteSnapshot{ | |
| 983 | + | Generation: 1, | |
| 984 | + | Entries: []ports.RecentRouteEntry{{Key: 2, Generation: 1, Name: "captured", Kind: ports.RouteKindLocal, Attention: true}}, | |
| 985 | + | } | |
| 985 | 986 | hints := palette.ContextualHints{ | |
| 986 | 987 | Kind: command.ContextHintRecentSessions, | |
| 987 | 988 | SelectedRank: 1, | |
| 988 | 989 | Recent: []palette.RecentSessionHint{{Rank: 1, Name: "captured"}}, | |
| 989 | 990 | } | |
| 990 | - | // Hold the registry lock: a call to recentSessions would block here. The | |
| 991 | - | // contextual snapshot must still compose because it has no live MRU lookup. | |
| 991 | + | // Hold the registry lock: contextual composition must use the immutable | |
| 992 | + | // attachment snapshot and never consult daemon session history. | |
| 992 | 993 | var contextual barState | |
| 993 | 994 | done := make(chan struct{}) | |
| 994 | 995 | d.mu.Lock() | |
| 995 | 996 | go func() { | |
| 996 | - | contextual = d.barStateForPaletteHints(sess, "", &hints, []recentSession{{name: "captured", attention: true}}) | |
| 997 | + | contextual = d.barStateForAttachmentPaletteHintsFor(sess, ac, "", &hints, snapshot) | |
| 997 | 998 | close(done) | |
| 998 | 999 | }() | |
| 999 | 1000 | select { | |
| @@ -1006,88 +1007,6 @@ func TestBarStateForContextualRecentUsesSnapshotAndNormalUsesLiveMRU(t *testing. | |||
| 1006 | 1007 | ||
| 1007 | 1008 | require.Empty(t, contextual.mru, "contextual composition must not read the live MRU") | |
| 1008 | 1009 | require.Equal(t, []rankedRecent{{rank: 1, name: "captured", attention: true, selected: true}}, contextual.rankedRecent) | |
| 1009 | - | ||
| 1010 | - | normal := d.barStateFor(sess, "") | |
| 1011 | - | require.Len(t, normal.mru, 1) | |
| 1012 | - | require.Equal(t, "live", normal.mru[0].name, "normal composition retains canonical live MRU") | |
| 1013 | - | } | |
| 1014 | - | ||
| 1015 | - | func TestBarStateForMRUFreshestFirstCapCurrentExcludedAndAttention(t *testing.T) { | |
| 1016 | - | p, releasePTY := newBlockingPTY(t) | |
| 1017 | - | d, sess, _, _ := newManualSessionWithPTYs(t, p) | |
| 1018 | - | defer releasePTY() | |
| 1019 | - | sess.name = "current" | |
| 1020 | - | sess.mruAt.Store(100) | |
| 1021 | - | for i := range 10 { | |
| 1022 | - | other := &session{sessionCore: sessionCore{id: domain.SessionID("s" + strconv.Itoa(i)), name: "s" + strconv.Itoa(i)}, tabs: []*tab{{attention: i == 8}}} | |
| 1023 | - | other.mruAt.Store(uint64(i + 1)) | |
| 1024 | - | d.sessions[other.id] = other | |
| 1025 | - | } | |
| 1026 | - | ||
| 1027 | - | state := d.barStateFor(sess, "") | |
| 1028 | - | ||
| 1029 | - | require.Len(t, state.mru, 9) | |
| 1030 | - | require.Equal(t, "s9", state.mru[0].name) | |
| 1031 | - | require.Equal(t, "s1", state.mru[8].name) | |
| 1032 | - | for _, got := range state.mru { | |
| 1033 | - | require.NotEqual(t, "current", got.name) | |
| 1034 | - | } | |
| 1035 | - | require.True(t, state.mru[1].attention, "s8 attention should be carried into MRU state") | |
| 1036 | - | } | |
| 1037 | - | ||
| 1038 | - | func TestBarStateForMRUExcludesEphemeralSessions(t *testing.T) { | |
| 1039 | - | p, releasePTY := newBlockingPTY(t) | |
| 1040 | - | d, current, _, _ := newManualSessionWithPTYs(t, p) | |
| 1041 | - | defer releasePTY() | |
| 1042 | - | ||
| 1043 | - | persistent := &session{sessionCore: sessionCore{id: "named", name: "named"}, tabs: []*tab{{}}} | |
| 1044 | - | persistent.mruAt.Store(1) | |
| 1045 | - | ephemeral := &session{sessionCore: sessionCore{id: "ephemeral", name: "1", ephemeral: true}, tabs: []*tab{{}}} | |
| 1046 | - | ephemeral.mruAt.Store(2) | |
| 1047 | - | d.sessions[persistent.id] = persistent | |
| 1048 | - | d.sessions[ephemeral.id] = ephemeral | |
| 1049 | - | ||
| 1050 | - | state := d.barStateFor(current, "") | |
| 1051 | - | ||
| 1052 | - | require.Len(t, state.mru, 1) | |
| 1053 | - | require.Equal(t, "named", state.mru[0].name) | |
| 1054 | - | } | |
| 1055 | - | ||
| 1056 | - | func TestBarStateForMRUZeroTimesUseDeterministicNameOrder(t *testing.T) { | |
| 1057 | - | p, releasePTY := newBlockingPTY(t) | |
| 1058 | - | d, sess, _, _ := newManualSessionWithPTYs(t, p) | |
| 1059 | - | defer releasePTY() | |
| 1060 | - | for _, name := range []string{"bravo", "alpha", "charlie"} { | |
| 1061 | - | d.sessions[domain.SessionID(name)] = &session{sessionCore: sessionCore{id: domain.SessionID(name), name: name}, tabs: []*tab{{}}} | |
| 1062 | - | } | |
| 1063 | - | ||
| 1064 | - | state := d.barStateFor(sess, "") | |
| 1065 | - | ||
| 1066 | - | require.GreaterOrEqual(t, len(state.mru), 3) | |
| 1067 | - | require.Equal(t, []string{"alpha", "bravo", "charlie"}, []string{state.mru[0].name, state.mru[1].name, state.mru[2].name}) | |
| 1068 | - | } | |
| 1069 | - | ||
| 1070 | - | func TestBarStateForMRURestoredStoppedSessionsUsePersistedOrder(t *testing.T) { | |
| 1071 | - | sz := domain.Size{Cols: 80, Rows: 24} | |
| 1072 | - | p1, releasePTY1 := newBlockingPTY(t) | |
| 1073 | - | defer releasePTY1() | |
| 1074 | - | p2, releasePTY2 := newBlockingPTY(t) | |
| 1075 | - | defer releasePTY2() | |
| 1076 | - | d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{}) | |
| 1077 | - | d.stopped["alpha"] = stoppedSession{name: "alpha", cwd: "/tmp/alpha", createdAt: 1, lastUsedSeq: 30} | |
| 1078 | - | d.stopped["zeta"] = stoppedSession{name: "zeta", cwd: "/tmp/zeta", createdAt: 1, lastUsedSeq: 20} | |
| 1079 | - | d.mruSeq.Store(30) | |
| 1080 | - | cur := &session{sessionCore: sessionCore{id: "cur", name: "current"}, tabs: []*tab{{}}} | |
| 1081 | - | d.sessions[cur.id] = cur | |
| 1082 | - | ||
| 1083 | - | _, err := createSessionForTest(d, "zeta", false, "/tmp/zeta", sz, terminalEnv{}, d.baseEnv) | |
| 1084 | - | require.NoError(t, err) | |
| 1085 | - | _, err = createSessionForTest(d, "alpha", false, "/tmp/alpha", sz, terminalEnv{}, d.baseEnv) | |
| 1086 | - | require.NoError(t, err) | |
| 1087 | - | ||
| 1088 | - | state := d.barStateFor(cur, "") | |
| 1089 | - | require.GreaterOrEqual(t, len(state.mru), 2) | |
| 1090 | - | require.Equal(t, []string{"alpha", "zeta"}, []string{state.mru[0].name, state.mru[1].name}) | |
| 1091 | 1010 | } | |
| 1092 | 1011 | ||
| 1093 | 1012 | func TestCapturePrimaryRenderStatePreservesContextualMRUModeThroughScratchReuse(t *testing.T) { | |
| @@ -968,32 +968,33 @@ func TestBarStateForPaletteHintsNormalizesTypedNilSession(t *testing.T) { | |||||
| 968 | var sess *session | 968 | var sess *session | ||
| 969 | 969 | ||||
| 970 | require.NotPanics(t, func() { | 970 | require.NotPanics(t, func() { | ||
| 971 | - | state := d.barStateForPaletteHints(sess, "feedback", nil, nil) | 971 | + | state := d.barStateForPaletteHints(sess, "feedback", nil) |
| 972 | require.Equal(t, "feedback", state.statusFeedback) | 972 | require.Equal(t, "feedback", state.statusFeedback) | ||
| 973 | require.Empty(t, state.status.session) | 973 | require.Empty(t, state.status.session) | ||
| 974 | }) | 974 | }) | ||
| 975 | } | 975 | } | ||
| 976 | 976 | ||||
| 977 | - | func TestBarStateForContextualRecentUsesSnapshotAndNormalUsesLiveMRU(t *testing.T) { | 977 | + | func TestBarStateForContextualRecentUsesClientSnapshot(t *testing.T) { |
| 978 | p, releasePTY := newBlockingPTY(t) | 978 | p, releasePTY := newBlockingPTY(t) | ||
| 979 | - | d, sess, _, _ := newManualSessionWithPTYs(t, p) | 979 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) |
| 980 | defer releasePTY() | 980 | defer releasePTY() | ||
| 981 | - | live := &session{sessionCore: sessionCore{id: "live", name: "live"}, tabs: []*tab{{}}} | |||
| 982 | - | live.mruAt.Store(1) | |||
| 983 | - | d.sessions[live.id] = live | |||
| 984 | 981 | ||||
| 982 | + | snapshot := ports.RecentRouteSnapshot{ | |||
| 983 | + | Generation: 1, | |||
| 984 | + | Entries: []ports.RecentRouteEntry{{Key: 2, Generation: 1, Name: "captured", Kind: ports.RouteKindLocal, Attention: true}}, | |||
| 985 | + | } | |||
| 985 | hints := palette.ContextualHints{ | 986 | hints := palette.ContextualHints{ | ||
| 986 | Kind: command.ContextHintRecentSessions, | 987 | Kind: command.ContextHintRecentSessions, | ||
| 987 | SelectedRank: 1, | 988 | SelectedRank: 1, | ||
| 988 | Recent: []palette.RecentSessionHint{{Rank: 1, Name: "captured"}}, | 989 | Recent: []palette.RecentSessionHint{{Rank: 1, Name: "captured"}}, | ||
| 989 | } | 990 | } | ||
| 990 | - | // Hold the registry lock: a call to recentSessions would block here. The | 991 | + | // Hold the registry lock: contextual composition must use the immutable |
| 991 | - | // contextual snapshot must still compose because it has no live MRU lookup. | 992 | + | // attachment snapshot and never consult daemon session history. |
| 992 | var contextual barState | 993 | var contextual barState | ||
| 993 | done := make(chan struct{}) | 994 | done := make(chan struct{}) | ||
| 994 | d.mu.Lock() | 995 | d.mu.Lock() | ||
| 995 | go func() { | 996 | go func() { | ||
| 996 | - | contextual = d.barStateForPaletteHints(sess, "", &hints, []recentSession{{name: "captured", attention: true}}) | 997 | + | contextual = d.barStateForAttachmentPaletteHintsFor(sess, ac, "", &hints, snapshot) |
| 997 | close(done) | 998 | close(done) | ||
| 998 | }() | 999 | }() | ||
| 999 | select { | 1000 | select { | ||
| @@ -1006,88 +1007,6 @@ func TestBarStateForContextualRecentUsesSnapshotAndNormalUsesLiveMRU(t *testing. | |||||
| 1006 | 1007 | ||||
| 1007 | require.Empty(t, contextual.mru, "contextual composition must not read the live MRU") | 1008 | require.Empty(t, contextual.mru, "contextual composition must not read the live MRU") | ||
| 1008 | require.Equal(t, []rankedRecent{{rank: 1, name: "captured", attention: true, selected: true}}, contextual.rankedRecent) | 1009 | require.Equal(t, []rankedRecent{{rank: 1, name: "captured", attention: true, selected: true}}, contextual.rankedRecent) | ||
| 1009 | - | ||||
| 1010 | - | normal := d.barStateFor(sess, "") | |||
| 1011 | - | require.Len(t, normal.mru, 1) | |||
| 1012 | - | require.Equal(t, "live", normal.mru[0].name, "normal composition retains canonical live MRU") | |||
| 1013 | - | } | |||
| 1014 | - | ||||
| 1015 | - | func TestBarStateForMRUFreshestFirstCapCurrentExcludedAndAttention(t *testing.T) { | |||
| 1016 | - | p, releasePTY := newBlockingPTY(t) | |||
| 1017 | - | d, sess, _, _ := newManualSessionWithPTYs(t, p) | |||
| 1018 | - | defer releasePTY() | |||
| 1019 | - | sess.name = "current" | |||
| 1020 | - | sess.mruAt.Store(100) | |||
| 1021 | - | for i := range 10 { | |||
| 1022 | - | other := &session{sessionCore: sessionCore{id: domain.SessionID("s" + strconv.Itoa(i)), name: "s" + strconv.Itoa(i)}, tabs: []*tab{{attention: i == 8}}} | |||
| 1023 | - | other.mruAt.Store(uint64(i + 1)) | |||
| 1024 | - | d.sessions[other.id] = other | |||
| 1025 | - | } | |||
| 1026 | - | ||||
| 1027 | - | state := d.barStateFor(sess, "") | |||
| 1028 | - | ||||
| 1029 | - | require.Len(t, state.mru, 9) | |||
| 1030 | - | require.Equal(t, "s9", state.mru[0].name) | |||
| 1031 | - | require.Equal(t, "s1", state.mru[8].name) | |||
| 1032 | - | for _, got := range state.mru { | |||
| 1033 | - | require.NotEqual(t, "current", got.name) | |||
| 1034 | - | } | |||
| 1035 | - | require.True(t, state.mru[1].attention, "s8 attention should be carried into MRU state") | |||
| 1036 | - | } | |||
| 1037 | - | ||||
| 1038 | - | func TestBarStateForMRUExcludesEphemeralSessions(t *testing.T) { | |||
| 1039 | - | p, releasePTY := newBlockingPTY(t) | |||
| 1040 | - | d, current, _, _ := newManualSessionWithPTYs(t, p) | |||
| 1041 | - | defer releasePTY() | |||
| 1042 | - | ||||
| 1043 | - | persistent := &session{sessionCore: sessionCore{id: "named", name: "named"}, tabs: []*tab{{}}} | |||
| 1044 | - | persistent.mruAt.Store(1) | |||
| 1045 | - | ephemeral := &session{sessionCore: sessionCore{id: "ephemeral", name: "1", ephemeral: true}, tabs: []*tab{{}}} | |||
| 1046 | - | ephemeral.mruAt.Store(2) | |||
| 1047 | - | d.sessions[persistent.id] = persistent | |||
| 1048 | - | d.sessions[ephemeral.id] = ephemeral | |||
| 1049 | - | ||||
| 1050 | - | state := d.barStateFor(current, "") | |||
| 1051 | - | ||||
| 1052 | - | require.Len(t, state.mru, 1) | |||
| 1053 | - | require.Equal(t, "named", state.mru[0].name) | |||
| 1054 | - | } | |||
| 1055 | - | ||||
| 1056 | - | func TestBarStateForMRUZeroTimesUseDeterministicNameOrder(t *testing.T) { | |||
| 1057 | - | p, releasePTY := newBlockingPTY(t) | |||
| 1058 | - | d, sess, _, _ := newManualSessionWithPTYs(t, p) | |||
| 1059 | - | defer releasePTY() | |||
| 1060 | - | for _, name := range []string{"bravo", "alpha", "charlie"} { | |||
| 1061 | - | d.sessions[domain.SessionID(name)] = &session{sessionCore: sessionCore{id: domain.SessionID(name), name: name}, tabs: []*tab{{}}} | |||
| 1062 | - | } | |||
| 1063 | - | ||||
| 1064 | - | state := d.barStateFor(sess, "") | |||
| 1065 | - | ||||
| 1066 | - | require.GreaterOrEqual(t, len(state.mru), 3) | |||
| 1067 | - | require.Equal(t, []string{"alpha", "bravo", "charlie"}, []string{state.mru[0].name, state.mru[1].name, state.mru[2].name}) | |||
| 1068 | - | } | |||
| 1069 | - | ||||
| 1070 | - | func TestBarStateForMRURestoredStoppedSessionsUsePersistedOrder(t *testing.T) { | |||
| 1071 | - | sz := domain.Size{Cols: 80, Rows: 24} | |||
| 1072 | - | p1, releasePTY1 := newBlockingPTY(t) | |||
| 1073 | - | defer releasePTY1() | |||
| 1074 | - | p2, releasePTY2 := newBlockingPTY(t) | |||
| 1075 | - | defer releasePTY2() | |||
| 1076 | - | d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{}) | |||
| 1077 | - | d.stopped["alpha"] = stoppedSession{name: "alpha", cwd: "/tmp/alpha", createdAt: 1, lastUsedSeq: 30} | |||
| 1078 | - | d.stopped["zeta"] = stoppedSession{name: "zeta", cwd: "/tmp/zeta", createdAt: 1, lastUsedSeq: 20} | |||
| 1079 | - | d.mruSeq.Store(30) | |||
| 1080 | - | cur := &session{sessionCore: sessionCore{id: "cur", name: "current"}, tabs: []*tab{{}}} | |||
| 1081 | - | d.sessions[cur.id] = cur | |||
| 1082 | - | ||||
| 1083 | - | _, err := createSessionForTest(d, "zeta", false, "/tmp/zeta", sz, terminalEnv{}, d.baseEnv) | |||
| 1084 | - | require.NoError(t, err) | |||
| 1085 | - | _, err = createSessionForTest(d, "alpha", false, "/tmp/alpha", sz, terminalEnv{}, d.baseEnv) | |||
| 1086 | - | require.NoError(t, err) | |||
| 1087 | - | ||||
| 1088 | - | state := d.barStateFor(cur, "") | |||
| 1089 | - | require.GreaterOrEqual(t, len(state.mru), 2) | |||
| 1090 | - | require.Equal(t, []string{"alpha", "zeta"}, []string{state.mru[0].name, state.mru[1].name}) | |||
| 1091 | } | 1010 | } | ||
| 1092 | 1011 | ||||
| 1093 | func TestCapturePrimaryRenderStatePreservesContextualMRUModeThroughScratchReuse(t *testing.T) { | 1012 | func TestCapturePrimaryRenderStatePreservesContextualMRUModeThroughScratchReuse(t *testing.T) { | ||
M internal/usecase/palette/contextual_hints.go +5 -2
| @@ -8,8 +8,11 @@ import ( | |||
| 8 | 8 | ||
| 9 | 9 | // RecentSessionHint is the immutable display data for one captured MRU rank. | |
| 10 | 10 | type RecentSessionHint struct { | |
| 11 | - | Rank int | |
| 12 | - | Name string | |
| 11 | + | Rank int | |
| 12 | + | Name string | |
| 13 | + | SnapshotGeneration uint64 | |
| 14 | + | Key uint64 | |
| 15 | + | Generation uint64 | |
| 13 | 16 | } | |
| 14 | 17 | ||
| 15 | 18 | // ContextualHints is immutable contextual palette guidance. | |
| @@ -8,8 +8,11 @@ import ( | |||||
| 8 | 8 | ||||
| 9 | // RecentSessionHint is the immutable display data for one captured MRU rank. | 9 | // RecentSessionHint is the immutable display data for one captured MRU rank. | ||
| 10 | type RecentSessionHint struct { | 10 | type RecentSessionHint struct { | ||
| 11 | - | Rank int | 11 | + | Rank int |
| 12 | - | Name string | 12 | + | Name string |
| 13 | + | SnapshotGeneration uint64 | |||
| 14 | + | Key uint64 | |||
| 15 | + | Generation uint64 | |||
| 13 | } | 16 | } | ||
| 14 | 17 | ||||
| 15 | // ContextualHints is immutable contextual palette guidance. | 18 | // ContextualHints is immutable contextual palette guidance. | ||