feat(client): drive picker previews through the broker
- commit
- 68ce008cb179fcbc164f3fb0492766060253e0b8
- author
- brice · · 2026-09-22 19:23 UTC
- parent
- 24e162b
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
19 files · +631 -547
M internal/usecase/client/attachment_session.go +15 -5
| @@ -301,6 +301,9 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||
| 301 | 301 | case protocol.ErrorMsg: | |
| 302 | 302 | return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: &ProtocolError{Code: typed.Code, Text: typed.Text}} | |
| 303 | 303 | case protocol.Detached: | |
| 304 | + | if typed.Reason == protocol.ReasonDetachToPicker { | |
| 305 | + | return attachmentLifecycleEnd(token, AttachmentDetachToPicker) | |
| 306 | + | } | |
| 304 | 307 | return AttachmentEvent{Token: token, Kind: AttachmentEventEnded} | |
| 305 | 308 | } | |
| 306 | 309 | case event := <-outgoing: | |
| @@ -322,11 +325,8 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||
| 322 | 325 | } | |
| 323 | 326 | } | |
| 324 | 327 | } | |
| 325 | - | if event.action == AttachmentDetachToPicker { | |
| 326 | - | return AttachmentEvent{Token: token, Kind: AttachmentEventEnded, Err: errDetachToPicker} | |
| 327 | - | } | |
| 328 | - | if event.action == AttachmentDetachAndExit { | |
| 329 | - | return AttachmentEvent{Token: token, Kind: AttachmentEventEnded, Err: errDetachAndExit} | |
| 328 | + | if event.action != 0 { | |
| 329 | + | return attachmentLifecycleEnd(token, event.action) | |
| 330 | 330 | } | |
| 331 | 331 | case <-ctx.Done(): | |
| 332 | 332 | return w.settle(ctx, fg, stream, token, ctx.Err()) | |
| @@ -336,6 +336,16 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||
| 336 | 336 | } | |
| 337 | 337 | } | |
| 338 | 338 | ||
| 339 | + | // attachmentLifecycleEnd gives local and daemon-triggered lifecycle actions | |
| 340 | + | // the same settlement path: drain/revoke foreground before publishing picker. | |
| 341 | + | func attachmentLifecycleEnd(token AttachmentToken, action AttachmentLifecycleActionKind) AttachmentEvent { | |
| 342 | + | err := errDetachToPicker | |
| 343 | + | if action == AttachmentDetachAndExit { | |
| 344 | + | err = errDetachAndExit | |
| 345 | + | } | |
| 346 | + | return AttachmentEvent{Token: token, Kind: AttachmentEventEnded, Err: err} | |
| 347 | + | } | |
| 348 | + | ||
| 339 | 349 | type attachmentUIForeground interface { | |
| 340 | 350 | actionableGeneration() uint64 | |
| 341 | 351 | uiReceipt(protocol.UIReceipt) | |
| @@ -301,6 +301,9 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||||
| 301 | case protocol.ErrorMsg: | 301 | case protocol.ErrorMsg: | ||
| 302 | return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: &ProtocolError{Code: typed.Code, Text: typed.Text}} | 302 | return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: &ProtocolError{Code: typed.Code, Text: typed.Text}} | ||
| 303 | case protocol.Detached: | 303 | case protocol.Detached: | ||
| 304 | + | if typed.Reason == protocol.ReasonDetachToPicker { | |||
| 305 | + | return attachmentLifecycleEnd(token, AttachmentDetachToPicker) | |||
| 306 | + | } | |||
| 304 | return AttachmentEvent{Token: token, Kind: AttachmentEventEnded} | 307 | return AttachmentEvent{Token: token, Kind: AttachmentEventEnded} | ||
| 305 | } | 308 | } | ||
| 306 | case event := <-outgoing: | 309 | case event := <-outgoing: | ||
| @@ -322,11 +325,8 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||||
| 322 | } | 325 | } | ||
| 323 | } | 326 | } | ||
| 324 | } | 327 | } | ||
| 325 | - | if event.action == AttachmentDetachToPicker { | 328 | + | if event.action != 0 { |
| 326 | - | return AttachmentEvent{Token: token, Kind: AttachmentEventEnded, Err: errDetachToPicker} | 329 | + | return attachmentLifecycleEnd(token, event.action) |
| 327 | - | } | |||
| 328 | - | if event.action == AttachmentDetachAndExit { | |||
| 329 | - | return AttachmentEvent{Token: token, Kind: AttachmentEventEnded, Err: errDetachAndExit} | |||
| 330 | } | 330 | } | ||
| 331 | case <-ctx.Done(): | 331 | case <-ctx.Done(): | ||
| 332 | return w.settle(ctx, fg, stream, token, ctx.Err()) | 332 | return w.settle(ctx, fg, stream, token, ctx.Err()) | ||
| @@ -336,6 +336,16 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||||
| 336 | } | 336 | } | ||
| 337 | } | 337 | } | ||
| 338 | 338 | ||||
| 339 | + | // attachmentLifecycleEnd gives local and daemon-triggered lifecycle actions | |||
| 340 | + | // the same settlement path: drain/revoke foreground before publishing picker. | |||
| 341 | + | func attachmentLifecycleEnd(token AttachmentToken, action AttachmentLifecycleActionKind) AttachmentEvent { | |||
| 342 | + | err := errDetachToPicker | |||
| 343 | + | if action == AttachmentDetachAndExit { | |||
| 344 | + | err = errDetachAndExit | |||
| 345 | + | } | |||
| 346 | + | return AttachmentEvent{Token: token, Kind: AttachmentEventEnded, Err: err} | |||
| 347 | + | } | |||
| 348 | + | ||||
| 339 | type attachmentUIForeground interface { | 349 | type attachmentUIForeground interface { | ||
| 340 | actionableGeneration() uint64 | 350 | actionableGeneration() uint64 | ||
| 341 | uiReceipt(protocol.UIReceipt) | 351 | uiReceipt(protocol.UIReceipt) | ||
M internal/usecase/client/broker_operations_test.go +4 -0
| @@ -177,6 +177,10 @@ func (s *brokerOpsTestService) Subscribe() (ports.BrokerSubscription, error) { | |||
| 177 | 177 | return nil, errors.New("broker ops test service does not support subscriptions") | |
| 178 | 178 | } | |
| 179 | 179 | ||
| 180 | + | func (s *brokerOpsTestService) SubscribePreview(ports.BrokerPreviewRequest) (ports.BrokerPreviewSubscription, error) { | |
| 181 | + | return nil, errors.New("broker ops test service does not support preview") | |
| 182 | + | } | |
| 183 | + | ||
| 180 | 184 | func (s *brokerOpsTestService) OpenStream(ctx context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |
| 181 | 185 | s.mu.Lock() | |
| 182 | 186 | s.opens = append(s.opens, request) | |
| @@ -177,6 +177,10 @@ func (s *brokerOpsTestService) Subscribe() (ports.BrokerSubscription, error) { | |||||
| 177 | return nil, errors.New("broker ops test service does not support subscriptions") | 177 | return nil, errors.New("broker ops test service does not support subscriptions") | ||
| 178 | } | 178 | } | ||
| 179 | 179 | ||||
| 180 | + | func (s *brokerOpsTestService) SubscribePreview(ports.BrokerPreviewRequest) (ports.BrokerPreviewSubscription, error) { | |||
| 181 | + | return nil, errors.New("broker ops test service does not support preview") | |||
| 182 | + | } | |||
| 183 | + | ||||
| 180 | func (s *brokerOpsTestService) OpenStream(ctx context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | 184 | func (s *brokerOpsTestService) OpenStream(ctx context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | ||
| 181 | s.mu.Lock() | 185 | s.mu.Lock() | ||
| 182 | s.opens = append(s.opens, request) | 186 | s.opens = append(s.opens, request) | ||
M internal/usecase/client/picker_catalogue.go +8 -6
| @@ -421,11 +421,11 @@ func (c *pickerCatalogue) projectLocked() { | |||
| 421 | 421 | lines = append(lines, protocol.PickerLine{ | |
| 422 | 422 | Key: pickerHostRowKey(observation), | |
| 423 | 423 | Kind: protocol.PickerLineHost, | |
| 424 | - | Label: origin, | |
| 424 | + | Label: "status", | |
| 425 | 425 | Detail: pickerHostDetail(observation), | |
| 426 | 426 | Status: pickerObservationStatus(observation, fresh), | |
| 427 | 427 | StatusDetail: pickerObservationReason(observation, fresh), | |
| 428 | - | Focusable: true, | |
| 428 | + | Focusable: false, | |
| 429 | 429 | }) | |
| 430 | 430 | ||
| 431 | 431 | for i := range observation.Sessions { | |
| @@ -668,8 +668,9 @@ type pickerHostDiagnostic struct { | |||
| 668 | 668 | Message string | |
| 669 | 669 | } | |
| 670 | 670 | ||
| 671 | - | // diagnostics reports every projected host that is not a clean, fresh, | |
| 672 | - | // compatible, reachable daemon, so the controller can surface it. | |
| 671 | + | // diagnostics reports actionable host failures. Ordinary observation progress | |
| 672 | + | // and catalogue aging remain visible on the host status row and never become | |
| 673 | + | // intrusive toasts. | |
| 673 | 674 | func (c *pickerCatalogue) diagnostics() []pickerHostDiagnostic { | |
| 674 | 675 | if c == nil { | |
| 675 | 676 | return nil | |
| @@ -685,13 +686,14 @@ func (c *pickerCatalogue) diagnostics() []pickerHostDiagnostic { | |||
| 685 | 686 | } | |
| 686 | 687 | observation := host.observation | |
| 687 | 688 | fresh := c.freshLocked(observation, now) | |
| 688 | - | if pickerObservationCompatible(observation) && observation.Availability == domain.RemoteAvailabilityReachable && fresh && !observation.Checking { | |
| 689 | + | status := pickerObservationStatus(observation, fresh) | |
| 690 | + | if status != protocol.PickerLineStatusDown && status != protocol.PickerLineStatusError && status != protocol.PickerLineStatusVersion { | |
| 689 | 691 | continue | |
| 690 | 692 | } | |
| 691 | 693 | diagnostics = append(diagnostics, pickerHostDiagnostic{ | |
| 692 | 694 | Key: pickerHostRowKey(observation), | |
| 693 | 695 | Origin: pickerOriginLabel(observation), | |
| 694 | - | Status: pickerObservationStatus(observation, fresh), | |
| 696 | + | Status: status, | |
| 695 | 697 | Message: pickerObservationReason(observation, fresh), | |
| 696 | 698 | }) | |
| 697 | 699 | } | |
| @@ -421,11 +421,11 @@ func (c *pickerCatalogue) projectLocked() { | |||||
| 421 | lines = append(lines, protocol.PickerLine{ | 421 | lines = append(lines, protocol.PickerLine{ | ||
| 422 | Key: pickerHostRowKey(observation), | 422 | Key: pickerHostRowKey(observation), | ||
| 423 | Kind: protocol.PickerLineHost, | 423 | Kind: protocol.PickerLineHost, | ||
| 424 | - | Label: origin, | 424 | + | Label: "status", |
| 425 | Detail: pickerHostDetail(observation), | 425 | Detail: pickerHostDetail(observation), | ||
| 426 | Status: pickerObservationStatus(observation, fresh), | 426 | Status: pickerObservationStatus(observation, fresh), | ||
| 427 | StatusDetail: pickerObservationReason(observation, fresh), | 427 | StatusDetail: pickerObservationReason(observation, fresh), | ||
| 428 | - | Focusable: true, | 428 | + | Focusable: false, |
| 429 | }) | 429 | }) | ||
| 430 | 430 | ||||
| 431 | for i := range observation.Sessions { | 431 | for i := range observation.Sessions { | ||
| @@ -668,8 +668,9 @@ type pickerHostDiagnostic struct { | |||||
| 668 | Message string | 668 | Message string | ||
| 669 | } | 669 | } | ||
| 670 | 670 | ||||
| 671 | - | // diagnostics reports every projected host that is not a clean, fresh, | 671 | + | // diagnostics reports actionable host failures. Ordinary observation progress |
| 672 | - | // compatible, reachable daemon, so the controller can surface it. | 672 | + | // and catalogue aging remain visible on the host status row and never become |
| 673 | + | // intrusive toasts. | |||
| 673 | func (c *pickerCatalogue) diagnostics() []pickerHostDiagnostic { | 674 | func (c *pickerCatalogue) diagnostics() []pickerHostDiagnostic { | ||
| 674 | if c == nil { | 675 | if c == nil { | ||
| 675 | return nil | 676 | return nil | ||
| @@ -685,13 +686,14 @@ func (c *pickerCatalogue) diagnostics() []pickerHostDiagnostic { | |||||
| 685 | } | 686 | } | ||
| 686 | observation := host.observation | 687 | observation := host.observation | ||
| 687 | fresh := c.freshLocked(observation, now) | 688 | fresh := c.freshLocked(observation, now) | ||
| 688 | - | if pickerObservationCompatible(observation) && observation.Availability == domain.RemoteAvailabilityReachable && fresh && !observation.Checking { | 689 | + | status := pickerObservationStatus(observation, fresh) |
| 690 | + | if status != protocol.PickerLineStatusDown && status != protocol.PickerLineStatusError && status != protocol.PickerLineStatusVersion { | |||
| 689 | continue | 691 | continue | ||
| 690 | } | 692 | } | ||
| 691 | diagnostics = append(diagnostics, pickerHostDiagnostic{ | 693 | diagnostics = append(diagnostics, pickerHostDiagnostic{ | ||
| 692 | Key: pickerHostRowKey(observation), | 694 | Key: pickerHostRowKey(observation), | ||
| 693 | Origin: pickerOriginLabel(observation), | 695 | Origin: pickerOriginLabel(observation), | ||
| 694 | - | Status: pickerObservationStatus(observation, fresh), | 696 | + | Status: status, |
| 695 | Message: pickerObservationReason(observation, fresh), | 697 | Message: pickerObservationReason(observation, fresh), | ||
| 696 | }) | 698 | }) | ||
| 697 | } | 699 | } | ||
M internal/usecase/client/picker_catalogue_test.go +16 -1
| @@ -2,6 +2,7 @@ package client | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | + | "strings" | |
| 5 | 6 | "sync" | |
| 6 | 7 | "testing" | |
| 7 | 8 | "time" | |
| @@ -115,6 +116,15 @@ func pickerLineByLabel(lines []protocol.PickerLine, label string) (protocol.Pick | |||
| 115 | 116 | return protocol.PickerLine{}, false | |
| 116 | 117 | } | |
| 117 | 118 | ||
| 119 | + | func pickerHostLine(lines []protocol.PickerLine) (protocol.PickerLine, bool) { | |
| 120 | + | for _, line := range lines { | |
| 121 | + | if line.Kind == protocol.PickerLineHost { | |
| 122 | + | return line, true | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | return protocol.PickerLine{}, false | |
| 126 | + | } | |
| 127 | + | ||
| 118 | 128 | func pickerSectionLabels(lines []protocol.PickerLine) []string { | |
| 119 | 129 | sections := make([]string, 0) | |
| 120 | 130 | for _, line := range lines { | |
| @@ -266,6 +276,9 @@ func TestPickerCatalogueProjectionCases(t *testing.T) { | |||
| 266 | 276 | } | |
| 267 | 277 | for label, status := range tt.wantStatus { | |
| 268 | 278 | line, ok := pickerLineByLabel(lines, label) | |
| 279 | + | if !ok && (label == "local" || strings.Contains(label, "@")) { | |
| 280 | + | line, ok = pickerHostLine(lines) | |
| 281 | + | } | |
| 269 | 282 | require.True(t, ok, "row %q must be projected", label) | |
| 270 | 283 | require.Equal(t, status, line.Status, "row %q badge", label) | |
| 271 | 284 | } | |
| @@ -878,8 +891,10 @@ func TestPickerCatalogueUnobservedDaemonProjects(t *testing.T) { | |||
| 878 | 891 | lines := catalogue.Lines() | |
| 879 | 892 | require.Equal(t, []string{"local"}, pickerSectionLabels(lines)) | |
| 880 | 893 | require.Equal(t, []string{"alpha"}, pickerSessionLabels(lines)) | |
| 881 | - | host, ok := pickerLineByLabel(lines, "local") | |
| 894 | + | host, ok := pickerHostLine(lines) | |
| 882 | 895 | require.True(t, ok) | |
| 896 | + | require.Equal(t, "status", host.Label) | |
| 897 | + | require.False(t, host.Focusable, "host status is informational, not a duplicate destination") | |
| 883 | 898 | require.Equal(t, protocol.PickerLineStatusStale, host.Status) | |
| 884 | 899 | require.Equal(t, domain.RemoteReasonRefreshing, host.StatusDetail, "an unobserved daemon refreshes, it is not a version mismatch") | |
| 885 | 900 | session, ok := pickerLineByLabel(lines, "alpha") | |
| @@ -2,6 +2,7 @@ package client | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "fmt" | 4 | "fmt" | ||
| 5 | + | "strings" | |||
| 5 | "sync" | 6 | "sync" | ||
| 6 | "testing" | 7 | "testing" | ||
| 7 | "time" | 8 | "time" | ||
| @@ -115,6 +116,15 @@ func pickerLineByLabel(lines []protocol.PickerLine, label string) (protocol.Pick | |||||
| 115 | return protocol.PickerLine{}, false | 116 | return protocol.PickerLine{}, false | ||
| 116 | } | 117 | } | ||
| 117 | 118 | ||||
| 119 | + | func pickerHostLine(lines []protocol.PickerLine) (protocol.PickerLine, bool) { | |||
| 120 | + | for _, line := range lines { | |||
| 121 | + | if line.Kind == protocol.PickerLineHost { | |||
| 122 | + | return line, true | |||
| 123 | + | } | |||
| 124 | + | } | |||
| 125 | + | return protocol.PickerLine{}, false | |||
| 126 | + | } | |||
| 127 | + | ||||
| 118 | func pickerSectionLabels(lines []protocol.PickerLine) []string { | 128 | func pickerSectionLabels(lines []protocol.PickerLine) []string { | ||
| 119 | sections := make([]string, 0) | 129 | sections := make([]string, 0) | ||
| 120 | for _, line := range lines { | 130 | for _, line := range lines { | ||
| @@ -266,6 +276,9 @@ func TestPickerCatalogueProjectionCases(t *testing.T) { | |||||
| 266 | } | 276 | } | ||
| 267 | for label, status := range tt.wantStatus { | 277 | for label, status := range tt.wantStatus { | ||
| 268 | line, ok := pickerLineByLabel(lines, label) | 278 | line, ok := pickerLineByLabel(lines, label) | ||
| 279 | + | if !ok && (label == "local" || strings.Contains(label, "@")) { | |||
| 280 | + | line, ok = pickerHostLine(lines) | |||
| 281 | + | } | |||
| 269 | require.True(t, ok, "row %q must be projected", label) | 282 | require.True(t, ok, "row %q must be projected", label) | ||
| 270 | require.Equal(t, status, line.Status, "row %q badge", label) | 283 | require.Equal(t, status, line.Status, "row %q badge", label) | ||
| 271 | } | 284 | } | ||
| @@ -878,8 +891,10 @@ func TestPickerCatalogueUnobservedDaemonProjects(t *testing.T) { | |||||
| 878 | lines := catalogue.Lines() | 891 | lines := catalogue.Lines() | ||
| 879 | require.Equal(t, []string{"local"}, pickerSectionLabels(lines)) | 892 | require.Equal(t, []string{"local"}, pickerSectionLabels(lines)) | ||
| 880 | require.Equal(t, []string{"alpha"}, pickerSessionLabels(lines)) | 893 | require.Equal(t, []string{"alpha"}, pickerSessionLabels(lines)) | ||
| 881 | - | host, ok := pickerLineByLabel(lines, "local") | 894 | + | host, ok := pickerHostLine(lines) |
| 882 | require.True(t, ok) | 895 | require.True(t, ok) | ||
| 896 | + | require.Equal(t, "status", host.Label) | |||
| 897 | + | require.False(t, host.Focusable, "host status is informational, not a duplicate destination") | |||
| 883 | require.Equal(t, protocol.PickerLineStatusStale, host.Status) | 898 | require.Equal(t, protocol.PickerLineStatusStale, host.Status) | ||
| 884 | require.Equal(t, domain.RemoteReasonRefreshing, host.StatusDetail, "an unobserved daemon refreshes, it is not a version mismatch") | 899 | require.Equal(t, domain.RemoteReasonRefreshing, host.StatusDetail, "an unobserved daemon refreshes, it is not a version mismatch") | ||
| 885 | session, ok := pickerLineByLabel(lines, "alpha") | 900 | session, ok := pickerLineByLabel(lines, "alpha") | ||
M internal/usecase/client/picker_composition.go +20 -0
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | ||
| 6 | 6 | "github.com/bnema/vev/internal/domain" | |
| 7 | 7 | "github.com/bnema/vev/internal/ports" | |
| 8 | + | "github.com/bnema/vev/internal/protocol" | |
| 8 | 9 | ) | |
| 9 | 10 | ||
| 10 | 11 | // Picker is the composition seam over the client-owned picker. | |
| @@ -75,6 +76,25 @@ func (p *Picker) ResolveKey(key string, base pickerResolveBase) (ports.BrokerOpe | |||
| 75 | 76 | } | |
| 76 | 77 | ||
| 77 | 78 | // SetOwnsInput updates picker input ownership at an attachment boundary. | |
| 79 | + | func (p *Picker) PreviewRequest(connection ports.BrokerConnectionID, stream ports.BrokerStreamID, size domain.Size) (ports.BrokerOpenStreamRequest, protocol.RemotePreviewRequest, bool) { | |
| 80 | + | if p == nil || p.controller == nil { | |
| 81 | + | return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false | |
| 82 | + | } | |
| 83 | + | return p.controller.PreviewRequest(connection, stream, size) | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func (p *Picker) SetPreview(preview protocol.RemotePreview) { | |
| 87 | + | if p != nil && p.controller != nil { | |
| 88 | + | p.controller.SetPreview(preview) | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func (p *Picker) ClearPreview() { | |
| 93 | + | if p != nil && p.controller != nil { | |
| 94 | + | p.controller.ClearPreview() | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | ||
| 78 | 98 | func (p *Picker) SetOwnsInput(owns bool) { | |
| 79 | 99 | if p == nil || p.controller == nil { | |
| 80 | 100 | return | |
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | 5 | ||||
| 6 | "github.com/bnema/vev/internal/domain" | 6 | "github.com/bnema/vev/internal/domain" | ||
| 7 | "github.com/bnema/vev/internal/ports" | 7 | "github.com/bnema/vev/internal/ports" | ||
| 8 | + | "github.com/bnema/vev/internal/protocol" | |||
| 8 | ) | 9 | ) | ||
| 9 | 10 | ||||
| 10 | // Picker is the composition seam over the client-owned picker. | 11 | // Picker is the composition seam over the client-owned picker. | ||
| @@ -75,6 +76,25 @@ func (p *Picker) ResolveKey(key string, base pickerResolveBase) (ports.BrokerOpe | |||||
| 75 | } | 76 | } | ||
| 76 | 77 | ||||
| 77 | // SetOwnsInput updates picker input ownership at an attachment boundary. | 78 | // SetOwnsInput updates picker input ownership at an attachment boundary. | ||
| 79 | + | func (p *Picker) PreviewRequest(connection ports.BrokerConnectionID, stream ports.BrokerStreamID, size domain.Size) (ports.BrokerOpenStreamRequest, protocol.RemotePreviewRequest, bool) { | |||
| 80 | + | if p == nil || p.controller == nil { | |||
| 81 | + | return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false | |||
| 82 | + | } | |||
| 83 | + | return p.controller.PreviewRequest(connection, stream, size) | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | func (p *Picker) SetPreview(preview protocol.RemotePreview) { | |||
| 87 | + | if p != nil && p.controller != nil { | |||
| 88 | + | p.controller.SetPreview(preview) | |||
| 89 | + | } | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | func (p *Picker) ClearPreview() { | |||
| 93 | + | if p != nil && p.controller != nil { | |||
| 94 | + | p.controller.ClearPreview() | |||
| 95 | + | } | |||
| 96 | + | } | |||
| 97 | + | ||||
| 78 | func (p *Picker) SetOwnsInput(owns bool) { | 98 | func (p *Picker) SetOwnsInput(owns bool) { | ||
| 79 | if p == nil || p.controller == nil { | 99 | if p == nil || p.controller == nil { | ||
| 80 | return | 100 | return | ||
M internal/usecase/client/picker_controller.go +93 -7
| @@ -9,6 +9,7 @@ import ( | |||
| 9 | 9 | "github.com/bnema/vev/internal/domain" | |
| 10 | 10 | "github.com/bnema/vev/internal/ports" | |
| 11 | 11 | "github.com/bnema/vev/internal/protocol" | |
| 12 | + | pickerusecase "github.com/bnema/vev/internal/usecase/picker" | |
| 12 | 13 | "github.com/bnema/vev/internal/usecase/ui" | |
| 13 | 14 | ) | |
| 14 | 15 | ||
| @@ -102,6 +103,7 @@ type pickerController struct { | |||
| 102 | 103 | // lastCommitKey is the catalogue key captured with the pending commit | |
| 103 | 104 | // decision, so TakeOp can hand a driver the exact committed row. | |
| 104 | 105 | lastCommitKey string | |
| 106 | + | preview pickerusecase.Preview | |
| 105 | 107 | } | |
| 106 | 108 | ||
| 107 | 109 | // newPickerController builds a picker over an empty catalogue. The picker owns | |
| @@ -192,7 +194,7 @@ func (p *pickerController) recordOpLocked(op pickerOp) { | |||
| 192 | 194 | p.lastCommitKey = p.commitKeyLocked() | |
| 193 | 195 | } | |
| 194 | 196 | p.lastOp = mergePickerOps(p.lastOp, op) | |
| 195 | - | if p.lastOp.commit { | |
| 197 | + | if p.lastOp.commit || p.lastOp.close { | |
| 196 | 198 | // Coalesce exactly one wakeup: a driver that has not yet run TakeOp | |
| 197 | 199 | // re-reads the accumulated decision, so a dropped signal is harmless. | |
| 198 | 200 | select { | |
| @@ -202,7 +204,7 @@ func (p *pickerController) recordOpLocked(op pickerOp) { | |||
| 202 | 204 | } | |
| 203 | 205 | } | |
| 204 | 206 | ||
| 205 | - | // OpsReady wakes the supervisor when a commit decision was recorded. It is a | |
| 207 | + | // OpsReady wakes the supervisor when a commit or close was recorded. It is a | |
| 206 | 208 | // capacity-one coalescing signal; TakeOp remains the authority on what was | |
| 207 | 209 | // decided, so a driver never infers the decision from the wakeup alone. | |
| 208 | 210 | func (p *pickerController) OpsReady() <-chan struct{} { | |
| @@ -289,7 +291,77 @@ func (p *pickerController) Render(size domain.Size) []byte { | |||
| 289 | 291 | if p.loop == nil { | |
| 290 | 292 | return nil | |
| 291 | 293 | } | |
| 292 | - | return p.renderer.render(p.loop, size, emptyPickerPreview()) | |
| 294 | + | return p.renderer.render(p.loop, size, p.preview) | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | func (p *pickerController) SetPreview(preview protocol.RemotePreview) { | |
| 298 | + | if p == nil { | |
| 299 | + | return | |
| 300 | + | } | |
| 301 | + | p.mu.Lock() | |
| 302 | + | defer p.mu.Unlock() | |
| 303 | + | rows := preview.FrameRows() | |
| 304 | + | if preview.Status != protocol.RemotePreviewOK || rows == nil { | |
| 305 | + | p.preview = pickerusecase.Preview{} | |
| 306 | + | return | |
| 307 | + | } | |
| 308 | + | p.preview = pickerusecase.Preview{Rows: rows, Width: int(preview.Width), Height: int(preview.Height)} | |
| 309 | + | } | |
| 310 | + | ||
| 311 | + | // PreviewRequest derives preview authority from the exact catalogue ref behind | |
| 312 | + | // the selected row. Only live exact sessions with an authoritative active tab | |
| 313 | + | // are previewable; create rows and stopped sessions are deliberately refused. | |
| 314 | + | func (p *pickerController) PreviewRequest(connection ports.BrokerConnectionID, stream ports.BrokerStreamID, size domain.Size) (ports.BrokerOpenStreamRequest, protocol.RemotePreviewRequest, bool) { | |
| 315 | + | key, ok := p.CursorKey() | |
| 316 | + | if !ok || p.catalogue == nil { | |
| 317 | + | return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false | |
| 318 | + | } | |
| 319 | + | ref, ok := p.catalogue.Ref(key) | |
| 320 | + | if !ok || ref.kind != pickerSelectionExact { | |
| 321 | + | return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false | |
| 322 | + | } | |
| 323 | + | route, err := p.catalogue.ResolveRef(ref, pickerResolveBase{Connection: connection, Stream: stream}) | |
| 324 | + | if err != nil { | |
| 325 | + | return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false | |
| 326 | + | } | |
| 327 | + | p.catalogue.mu.Lock() | |
| 328 | + | authority := p.catalogue.authorityForRefLocked(ref) | |
| 329 | + | p.catalogue.mu.Unlock() | |
| 330 | + | if !authority.found { | |
| 331 | + | return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false | |
| 332 | + | } | |
| 333 | + | session, ok := pickerFindSession(authority.observation.Sessions, ref.lifecycle) | |
| 334 | + | if !ok || session.State == "down" || session.ActiveTabID == "" { | |
| 335 | + | return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false | |
| 336 | + | } | |
| 337 | + | viewport := pickerPreviewSize(size) | |
| 338 | + | endpoint := authority.observation.Endpoint | |
| 339 | + | if authority.observation.Local { | |
| 340 | + | // RemoteSessionTarget still requires a non-empty route identity for the | |
| 341 | + | // local daemon; "local" is the broker's canonical local authority. | |
| 342 | + | endpoint = "local" | |
| 343 | + | } | |
| 344 | + | target := domain.RemoteSessionTarget{Endpoint: endpoint, DisplayOrigin: authority.observation.DisplayOrigin, LifecycleID: session.LifecycleID, SessionName: session.Name, LiveTabID: domain.TabStableID(session.ActiveTabID)} | |
| 345 | + | preview := protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: clampPreviewDimension(viewport.Cols, protocol.RemotePreviewMaxWidth), Height: clampPreviewDimension(viewport.Rows, protocol.RemotePreviewMaxHeight)} | |
| 346 | + | route.Purpose = ports.BrokerStreamObservation | |
| 347 | + | route.Admission = 0 | |
| 348 | + | route.Name = "" | |
| 349 | + | route.Target = protocol.ExactSessionTarget{} | |
| 350 | + | route.Env = nil | |
| 351 | + | route.StartMode = ports.BrokerDaemonExistingOnly | |
| 352 | + | if route.Validate() != nil || protocol.ValidateRemotePreviewRequest(preview) != nil { | |
| 353 | + | return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false | |
| 354 | + | } | |
| 355 | + | return route, preview, true | |
| 356 | + | } | |
| 357 | + | ||
| 358 | + | func (p *pickerController) ClearPreview() { | |
| 359 | + | if p == nil { | |
| 360 | + | return | |
| 361 | + | } | |
| 362 | + | p.mu.Lock() | |
| 363 | + | p.preview = pickerusecase.Preview{} | |
| 364 | + | p.mu.Unlock() | |
| 293 | 365 | } | |
| 294 | 366 | ||
| 295 | 367 | // RenderNotice composes the newest bounded catalogue notice as a client-local | |
| @@ -417,13 +489,27 @@ func (p *pickerController) handleRead(data []byte) bool { | |||
| 417 | 489 | if !consumed { | |
| 418 | 490 | return false | |
| 419 | 491 | } | |
| 420 | - | if p.loop == nil || outcome.interaction != pickerGeneration || outcome.generation != pickerGeneration { | |
| 421 | - | // The picker owns input before its first publication: the batch is | |
| 422 | - | // consumed and dropped, never forwarded. | |
| 492 | + | if outcome.interaction != pickerGeneration || outcome.generation != pickerGeneration { | |
| 423 | 493 | return true | |
| 424 | 494 | } | |
| 425 | - | op, _ := applyPickerBatch(p.loop, outcome.events) | |
| 495 | + | if p.loop == nil { | |
| 496 | + | // Before a catalogue exists only an explicit interrupt is actionable. | |
| 497 | + | // Use decoded events so a pasted Ctrl-C is not mistaken for an exit. | |
| 498 | + | for _, event := range outcome.events { | |
| 499 | + | if event.kind == pickerEventKey && event.key == "Ctrl+C" { | |
| 500 | + | p.recordOpLocked(pickerOp{close: true}) | |
| 501 | + | } | |
| 502 | + | } | |
| 503 | + | return true | |
| 504 | + | } | |
| 505 | + | op, changed := applyPickerBatch(p.loop, outcome.events) | |
| 426 | 506 | p.recordOpLocked(op) | |
| 507 | + | if changed { | |
| 508 | + | select { | |
| 509 | + | case p.opsReady <- struct{}{}: | |
| 510 | + | default: | |
| 511 | + | } | |
| 512 | + | } | |
| 427 | 513 | return true | |
| 428 | 514 | } | |
| 429 | 515 | ||
| @@ -9,6 +9,7 @@ import ( | |||||
| 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/ports" | ||
| 11 | "github.com/bnema/vev/internal/protocol" | 11 | "github.com/bnema/vev/internal/protocol" | ||
| 12 | + | pickerusecase "github.com/bnema/vev/internal/usecase/picker" | |||
| 12 | "github.com/bnema/vev/internal/usecase/ui" | 13 | "github.com/bnema/vev/internal/usecase/ui" | ||
| 13 | ) | 14 | ) | ||
| 14 | 15 | ||||
| @@ -102,6 +103,7 @@ type pickerController struct { | |||||
| 102 | // lastCommitKey is the catalogue key captured with the pending commit | 103 | // lastCommitKey is the catalogue key captured with the pending commit | ||
| 103 | // decision, so TakeOp can hand a driver the exact committed row. | 104 | // decision, so TakeOp can hand a driver the exact committed row. | ||
| 104 | lastCommitKey string | 105 | lastCommitKey string | ||
| 106 | + | preview pickerusecase.Preview | |||
| 105 | } | 107 | } | ||
| 106 | 108 | ||||
| 107 | // newPickerController builds a picker over an empty catalogue. The picker owns | 109 | // newPickerController builds a picker over an empty catalogue. The picker owns | ||
| @@ -192,7 +194,7 @@ func (p *pickerController) recordOpLocked(op pickerOp) { | |||||
| 192 | p.lastCommitKey = p.commitKeyLocked() | 194 | p.lastCommitKey = p.commitKeyLocked() | ||
| 193 | } | 195 | } | ||
| 194 | p.lastOp = mergePickerOps(p.lastOp, op) | 196 | p.lastOp = mergePickerOps(p.lastOp, op) | ||
| 195 | - | if p.lastOp.commit { | 197 | + | if p.lastOp.commit || p.lastOp.close { |
| 196 | // Coalesce exactly one wakeup: a driver that has not yet run TakeOp | 198 | // Coalesce exactly one wakeup: a driver that has not yet run TakeOp | ||
| 197 | // re-reads the accumulated decision, so a dropped signal is harmless. | 199 | // re-reads the accumulated decision, so a dropped signal is harmless. | ||
| 198 | select { | 200 | select { | ||
| @@ -202,7 +204,7 @@ func (p *pickerController) recordOpLocked(op pickerOp) { | |||||
| 202 | } | 204 | } | ||
| 203 | } | 205 | } | ||
| 204 | 206 | ||||
| 205 | - | // OpsReady wakes the supervisor when a commit decision was recorded. It is a | 207 | + | // OpsReady wakes the supervisor when a commit or close was recorded. It is a |
| 206 | // capacity-one coalescing signal; TakeOp remains the authority on what was | 208 | // capacity-one coalescing signal; TakeOp remains the authority on what was | ||
| 207 | // decided, so a driver never infers the decision from the wakeup alone. | 209 | // decided, so a driver never infers the decision from the wakeup alone. | ||
| 208 | func (p *pickerController) OpsReady() <-chan struct{} { | 210 | func (p *pickerController) OpsReady() <-chan struct{} { | ||
| @@ -289,7 +291,77 @@ func (p *pickerController) Render(size domain.Size) []byte { | |||||
| 289 | if p.loop == nil { | 291 | if p.loop == nil { | ||
| 290 | return nil | 292 | return nil | ||
| 291 | } | 293 | } | ||
| 292 | - | return p.renderer.render(p.loop, size, emptyPickerPreview()) | 294 | + | return p.renderer.render(p.loop, size, p.preview) |
| 295 | + | } | |||
| 296 | + | ||||
| 297 | + | func (p *pickerController) SetPreview(preview protocol.RemotePreview) { | |||
| 298 | + | if p == nil { | |||
| 299 | + | return | |||
| 300 | + | } | |||
| 301 | + | p.mu.Lock() | |||
| 302 | + | defer p.mu.Unlock() | |||
| 303 | + | rows := preview.FrameRows() | |||
| 304 | + | if preview.Status != protocol.RemotePreviewOK || rows == nil { | |||
| 305 | + | p.preview = pickerusecase.Preview{} | |||
| 306 | + | return | |||
| 307 | + | } | |||
| 308 | + | p.preview = pickerusecase.Preview{Rows: rows, Width: int(preview.Width), Height: int(preview.Height)} | |||
| 309 | + | } | |||
| 310 | + | ||||
| 311 | + | // PreviewRequest derives preview authority from the exact catalogue ref behind | |||
| 312 | + | // the selected row. Only live exact sessions with an authoritative active tab | |||
| 313 | + | // are previewable; create rows and stopped sessions are deliberately refused. | |||
| 314 | + | func (p *pickerController) PreviewRequest(connection ports.BrokerConnectionID, stream ports.BrokerStreamID, size domain.Size) (ports.BrokerOpenStreamRequest, protocol.RemotePreviewRequest, bool) { | |||
| 315 | + | key, ok := p.CursorKey() | |||
| 316 | + | if !ok || p.catalogue == nil { | |||
| 317 | + | return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false | |||
| 318 | + | } | |||
| 319 | + | ref, ok := p.catalogue.Ref(key) | |||
| 320 | + | if !ok || ref.kind != pickerSelectionExact { | |||
| 321 | + | return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false | |||
| 322 | + | } | |||
| 323 | + | route, err := p.catalogue.ResolveRef(ref, pickerResolveBase{Connection: connection, Stream: stream}) | |||
| 324 | + | if err != nil { | |||
| 325 | + | return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false | |||
| 326 | + | } | |||
| 327 | + | p.catalogue.mu.Lock() | |||
| 328 | + | authority := p.catalogue.authorityForRefLocked(ref) | |||
| 329 | + | p.catalogue.mu.Unlock() | |||
| 330 | + | if !authority.found { | |||
| 331 | + | return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false | |||
| 332 | + | } | |||
| 333 | + | session, ok := pickerFindSession(authority.observation.Sessions, ref.lifecycle) | |||
| 334 | + | if !ok || session.State == "down" || session.ActiveTabID == "" { | |||
| 335 | + | return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false | |||
| 336 | + | } | |||
| 337 | + | viewport := pickerPreviewSize(size) | |||
| 338 | + | endpoint := authority.observation.Endpoint | |||
| 339 | + | if authority.observation.Local { | |||
| 340 | + | // RemoteSessionTarget still requires a non-empty route identity for the | |||
| 341 | + | // local daemon; "local" is the broker's canonical local authority. | |||
| 342 | + | endpoint = "local" | |||
| 343 | + | } | |||
| 344 | + | target := domain.RemoteSessionTarget{Endpoint: endpoint, DisplayOrigin: authority.observation.DisplayOrigin, LifecycleID: session.LifecycleID, SessionName: session.Name, LiveTabID: domain.TabStableID(session.ActiveTabID)} | |||
| 345 | + | preview := protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: clampPreviewDimension(viewport.Cols, protocol.RemotePreviewMaxWidth), Height: clampPreviewDimension(viewport.Rows, protocol.RemotePreviewMaxHeight)} | |||
| 346 | + | route.Purpose = ports.BrokerStreamObservation | |||
| 347 | + | route.Admission = 0 | |||
| 348 | + | route.Name = "" | |||
| 349 | + | route.Target = protocol.ExactSessionTarget{} | |||
| 350 | + | route.Env = nil | |||
| 351 | + | route.StartMode = ports.BrokerDaemonExistingOnly | |||
| 352 | + | if route.Validate() != nil || protocol.ValidateRemotePreviewRequest(preview) != nil { | |||
| 353 | + | return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false | |||
| 354 | + | } | |||
| 355 | + | return route, preview, true | |||
| 356 | + | } | |||
| 357 | + | ||||
| 358 | + | func (p *pickerController) ClearPreview() { | |||
| 359 | + | if p == nil { | |||
| 360 | + | return | |||
| 361 | + | } | |||
| 362 | + | p.mu.Lock() | |||
| 363 | + | p.preview = pickerusecase.Preview{} | |||
| 364 | + | p.mu.Unlock() | |||
| 293 | } | 365 | } | ||
| 294 | 366 | ||||
| 295 | // RenderNotice composes the newest bounded catalogue notice as a client-local | 367 | // RenderNotice composes the newest bounded catalogue notice as a client-local | ||
| @@ -417,13 +489,27 @@ func (p *pickerController) handleRead(data []byte) bool { | |||||
| 417 | if !consumed { | 489 | if !consumed { | ||
| 418 | return false | 490 | return false | ||
| 419 | } | 491 | } | ||
| 420 | - | if p.loop == nil || outcome.interaction != pickerGeneration || outcome.generation != pickerGeneration { | 492 | + | if outcome.interaction != pickerGeneration || outcome.generation != pickerGeneration { |
| 421 | - | // The picker owns input before its first publication: the batch is | |||
| 422 | - | // consumed and dropped, never forwarded. | |||
| 423 | return true | 493 | return true | ||
| 424 | } | 494 | } | ||
| 425 | - | op, _ := applyPickerBatch(p.loop, outcome.events) | 495 | + | if p.loop == nil { |
| 496 | + | // Before a catalogue exists only an explicit interrupt is actionable. | |||
| 497 | + | // Use decoded events so a pasted Ctrl-C is not mistaken for an exit. | |||
| 498 | + | for _, event := range outcome.events { | |||
| 499 | + | if event.kind == pickerEventKey && event.key == "Ctrl+C" { | |||
| 500 | + | p.recordOpLocked(pickerOp{close: true}) | |||
| 501 | + | } | |||
| 502 | + | } | |||
| 503 | + | return true | |||
| 504 | + | } | |||
| 505 | + | op, changed := applyPickerBatch(p.loop, outcome.events) | |||
| 426 | p.recordOpLocked(op) | 506 | p.recordOpLocked(op) | ||
| 507 | + | if changed { | |||
| 508 | + | select { | |||
| 509 | + | case p.opsReady <- struct{}{}: | |||
| 510 | + | default: | |||
| 511 | + | } | |||
| 512 | + | } | |||
| 427 | return true | 513 | return true | ||
| 428 | } | 514 | } | ||
| 429 | 515 | ||||
M internal/usecase/client/picker_controller_test.go +11 -8
| @@ -240,7 +240,7 @@ func TestPickerControllerInputRouting(t *testing.T) { | |||
| 240 | 240 | wantOp pickerOp | |
| 241 | 241 | }{ | |
| 242 | 242 | {name: "down", chunk: []byte("\x1b[B"), wantCursor: "beta"}, | |
| 243 | - | {name: "up rests on the host row", chunk: []byte("\x1b[A"), wantNoSelection: true}, | |
| 243 | + | {name: "up skips informational host row", chunk: []byte("\x1b[A"), wantCursor: "alpha"}, | |
| 244 | 244 | {name: "search entry", chunk: []byte("/"), wantCursor: "alpha", wantSearch: true}, | |
| 245 | 245 | {name: "search text", chunk: []byte("/al"), wantCursor: "alpha", wantSearch: true, wantQuery: "al"}, | |
| 246 | 246 | {name: "enter commits", chunk: []byte("/al\r"), wantCursor: "alpha", wantSearch: true, wantQuery: "al", wantOp: pickerOp{commit: true}}, | |
| @@ -299,12 +299,12 @@ func TestPickerControllerNotOwningInputLeavesReadUnconsumed(t *testing.T) { | |||
| 299 | 299 | require.True(t, controller.ConsumeTerminalRead([]byte("j"))) | |
| 300 | 300 | } | |
| 301 | 301 | ||
| 302 | - | func TestPickerControllerSurfacesProgressAndFailuresAsNotices(t *testing.T) { | |
| 302 | + | func TestPickerControllerKeepsProgressInRowsAndSurfacesFailuresAsNotices(t *testing.T) { | |
| 303 | 303 | controller, clock := pickerTestController(t) | |
| 304 | 304 | checking := pickerTestLocalObservation(clock.Now(), pickerTestSession("alpha", 1, catalogue_Up)) | |
| 305 | 305 | checking.Checking = true | |
| 306 | 306 | controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{checking}}) | |
| 307 | - | require.NotEmpty(t, controller.RenderNotice(domain.Size{Cols: 80, Rows: 24}), "a refreshing host is surfaced as progress") | |
| 307 | + | require.Empty(t, controller.RenderNotice(domain.Size{Cols: 80, Rows: 24}), "ordinary refreshing progress stays in the host row") | |
| 308 | 308 | ||
| 309 | 309 | unreachable := pickerTestLocalObservation(clock.Now()) | |
| 310 | 310 | unreachable.Availability = domain.RemoteAvailabilityUnreachable | |
| @@ -585,8 +585,8 @@ func TestPickerControllerApplySnapshotRejectsInvalidSnapshot(t *testing.T) { | |||
| 585 | 585 | ||
| 586 | 586 | // TestPickerControllerUnobservedDaemonIsRefreshingNotVersionMismatch pins the | |
| 587 | 587 | // availability-first classification at the controller boundary: an unobserved | |
| 588 | - | // daemon (Availability Unknown, ProtocolVersion 0) surfaces a refreshing | |
| 589 | - | // notice, never a version_mismatch. | |
| 588 | + | // daemon (Availability Unknown, ProtocolVersion 0) keeps refreshing on its | |
| 589 | + | // row and surfaces no failure toast. | |
| 590 | 590 | func TestPickerControllerUnobservedDaemonIsRefreshingNotVersionMismatch(t *testing.T) { | |
| 591 | 591 | controller, clock := pickerTestController(t) | |
| 592 | 592 | unobserved := pickerTestUnobservedObservation(clock.Now(), pickerTestSession("alpha", 1, catalogue_Up)) | |
| @@ -595,9 +595,12 @@ func TestPickerControllerUnobservedDaemonIsRefreshingNotVersionMismatch(t *testi | |||
| 595 | 595 | controller.mu.Lock() | |
| 596 | 596 | active := controller.notices.Active(clock.Now()) | |
| 597 | 597 | controller.mu.Unlock() | |
| 598 | - | require.Len(t, active, 1) | |
| 599 | - | require.Equal(t, "local: "+domain.RemoteReasonRefreshing, active[0].Message) | |
| 600 | - | require.NotContains(t, active[0].Message, domain.RemoteReasonVersionMismatch) | |
| 598 | + | require.Empty(t, active) | |
| 599 | + | lines := controller.Catalogue().Lines() | |
| 600 | + | host, ok := pickerHostLine(lines) | |
| 601 | + | require.True(t, ok) | |
| 602 | + | require.Equal(t, domain.RemoteReasonRefreshing, host.StatusDetail) | |
| 603 | + | require.NotEqual(t, domain.RemoteReasonVersionMismatch, host.StatusDetail) | |
| 601 | 604 | } | |
| 602 | 605 | ||
| 603 | 606 | // TestPickerControllerRenderNoticeSelectsNewest pins that the notice shown is | |
| @@ -240,7 +240,7 @@ func TestPickerControllerInputRouting(t *testing.T) { | |||||
| 240 | wantOp pickerOp | 240 | wantOp pickerOp | ||
| 241 | }{ | 241 | }{ | ||
| 242 | {name: "down", chunk: []byte("\x1b[B"), wantCursor: "beta"}, | 242 | {name: "down", chunk: []byte("\x1b[B"), wantCursor: "beta"}, | ||
| 243 | - | {name: "up rests on the host row", chunk: []byte("\x1b[A"), wantNoSelection: true}, | 243 | + | {name: "up skips informational host row", chunk: []byte("\x1b[A"), wantCursor: "alpha"}, |
| 244 | {name: "search entry", chunk: []byte("/"), wantCursor: "alpha", wantSearch: true}, | 244 | {name: "search entry", chunk: []byte("/"), wantCursor: "alpha", wantSearch: true}, | ||
| 245 | {name: "search text", chunk: []byte("/al"), wantCursor: "alpha", wantSearch: true, wantQuery: "al"}, | 245 | {name: "search text", chunk: []byte("/al"), wantCursor: "alpha", wantSearch: true, wantQuery: "al"}, | ||
| 246 | {name: "enter commits", chunk: []byte("/al\r"), wantCursor: "alpha", wantSearch: true, wantQuery: "al", wantOp: pickerOp{commit: true}}, | 246 | {name: "enter commits", chunk: []byte("/al\r"), wantCursor: "alpha", wantSearch: true, wantQuery: "al", wantOp: pickerOp{commit: true}}, | ||
| @@ -299,12 +299,12 @@ func TestPickerControllerNotOwningInputLeavesReadUnconsumed(t *testing.T) { | |||||
| 299 | require.True(t, controller.ConsumeTerminalRead([]byte("j"))) | 299 | require.True(t, controller.ConsumeTerminalRead([]byte("j"))) | ||
| 300 | } | 300 | } | ||
| 301 | 301 | ||||
| 302 | - | func TestPickerControllerSurfacesProgressAndFailuresAsNotices(t *testing.T) { | 302 | + | func TestPickerControllerKeepsProgressInRowsAndSurfacesFailuresAsNotices(t *testing.T) { |
| 303 | controller, clock := pickerTestController(t) | 303 | controller, clock := pickerTestController(t) | ||
| 304 | checking := pickerTestLocalObservation(clock.Now(), pickerTestSession("alpha", 1, catalogue_Up)) | 304 | checking := pickerTestLocalObservation(clock.Now(), pickerTestSession("alpha", 1, catalogue_Up)) | ||
| 305 | checking.Checking = true | 305 | checking.Checking = true | ||
| 306 | controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{checking}}) | 306 | controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{checking}}) | ||
| 307 | - | require.NotEmpty(t, controller.RenderNotice(domain.Size{Cols: 80, Rows: 24}), "a refreshing host is surfaced as progress") | 307 | + | require.Empty(t, controller.RenderNotice(domain.Size{Cols: 80, Rows: 24}), "ordinary refreshing progress stays in the host row") |
| 308 | 308 | ||||
| 309 | unreachable := pickerTestLocalObservation(clock.Now()) | 309 | unreachable := pickerTestLocalObservation(clock.Now()) | ||
| 310 | unreachable.Availability = domain.RemoteAvailabilityUnreachable | 310 | unreachable.Availability = domain.RemoteAvailabilityUnreachable | ||
| @@ -585,8 +585,8 @@ func TestPickerControllerApplySnapshotRejectsInvalidSnapshot(t *testing.T) { | |||||
| 585 | 585 | ||||
| 586 | // TestPickerControllerUnobservedDaemonIsRefreshingNotVersionMismatch pins the | 586 | // TestPickerControllerUnobservedDaemonIsRefreshingNotVersionMismatch pins the | ||
| 587 | // availability-first classification at the controller boundary: an unobserved | 587 | // availability-first classification at the controller boundary: an unobserved | ||
| 588 | - | // daemon (Availability Unknown, ProtocolVersion 0) surfaces a refreshing | 588 | + | // daemon (Availability Unknown, ProtocolVersion 0) keeps refreshing on its |
| 589 | - | // notice, never a version_mismatch. | 589 | + | // row and surfaces no failure toast. |
| 590 | func TestPickerControllerUnobservedDaemonIsRefreshingNotVersionMismatch(t *testing.T) { | 590 | func TestPickerControllerUnobservedDaemonIsRefreshingNotVersionMismatch(t *testing.T) { | ||
| 591 | controller, clock := pickerTestController(t) | 591 | controller, clock := pickerTestController(t) | ||
| 592 | unobserved := pickerTestUnobservedObservation(clock.Now(), pickerTestSession("alpha", 1, catalogue_Up)) | 592 | unobserved := pickerTestUnobservedObservation(clock.Now(), pickerTestSession("alpha", 1, catalogue_Up)) | ||
| @@ -595,9 +595,12 @@ func TestPickerControllerUnobservedDaemonIsRefreshingNotVersionMismatch(t *testi | |||||
| 595 | controller.mu.Lock() | 595 | controller.mu.Lock() | ||
| 596 | active := controller.notices.Active(clock.Now()) | 596 | active := controller.notices.Active(clock.Now()) | ||
| 597 | controller.mu.Unlock() | 597 | controller.mu.Unlock() | ||
| 598 | - | require.Len(t, active, 1) | 598 | + | require.Empty(t, active) |
| 599 | - | require.Equal(t, "local: "+domain.RemoteReasonRefreshing, active[0].Message) | 599 | + | lines := controller.Catalogue().Lines() |
| 600 | - | require.NotContains(t, active[0].Message, domain.RemoteReasonVersionMismatch) | 600 | + | host, ok := pickerHostLine(lines) |
| 601 | + | require.True(t, ok) | |||
| 602 | + | require.Equal(t, domain.RemoteReasonRefreshing, host.StatusDetail) | |||
| 603 | + | require.NotEqual(t, domain.RemoteReasonVersionMismatch, host.StatusDetail) | |||
| 601 | } | 604 | } | ||
| 602 | 605 | ||||
| 603 | // TestPickerControllerRenderNoticeSelectsNewest pins that the notice shown is | 606 | // TestPickerControllerRenderNoticeSelectsNewest pins that the notice shown is | ||
D internal/usecase/client/picker_lease.go +0 -277
| @@ -1,277 +0,0 @@ | |||
| 1 | - | package client | |
| 2 | - | ||
| 3 | - | import "github.com/bnema/vev/internal/protocol" | |
| 4 | - | ||
| 5 | - | // This file owns the client-side presentation lease for one client-picker | |
| 6 | - | // interaction. The daemon keeps mutation authority (it resolves opaque | |
| 7 | - | // row keys and performs the handoff); the lease decides who writes the | |
| 8 | - | // terminal: while it is acquiring, owned, or releasing the client | |
| 9 | - | // consumes picker input and never lets a daemon paint overwrite the modal, | |
| 10 | - | // yet every accepted daemon frame is still applied to the output shadow and | |
| 11 | - | // acknowledged inside the bounded window. | |
| 12 | - | // | |
| 13 | - | // The lease is value-oriented and owns no locks: the attach loop is its | |
| 14 | - | // only caller and already owns the terminal writer. | |
| 15 | - | ||
| 16 | - | // pickerLeaseState is the client-side presentation state of one | |
| 17 | - | // client-picker interaction. | |
| 18 | - | type pickerLeaseState uint8 | |
| 19 | - | ||
| 20 | - | const ( | |
| 21 | - | // pickerLeaseClosed displays daemon output normally. | |
| 22 | - | pickerLeaseClosed pickerLeaseState = iota | |
| 23 | - | // pickerLeaseAcquiring validated a snapshot whose acquisition barrier | |
| 24 | - | // (epoch, state) is not applied yet. Daemon frames up to that barrier | |
| 25 | - | // are still displayed; the picker frame is not. | |
| 26 | - | pickerLeaseAcquiring | |
| 27 | - | // pickerLeaseOwned displays the picker frame and consumes picker | |
| 28 | - | // input. Daemon frames are applied and acknowledged but never written. | |
| 29 | - | pickerLeaseOwned | |
| 30 | - | // pickerLeaseReleasing retired the interaction on PickerClose. The | |
| 31 | - | // picker frame stays until one authoritative full paint arrives; then | |
| 32 | - | // the client releases the terminal and resumes normal input. | |
| 33 | - | pickerLeaseReleasing | |
| 34 | - | ) | |
| 35 | - | ||
| 36 | - | // pickerLeaseEvent reports the presentation transition one accepted daemon | |
| 37 | - | // frame triggers. | |
| 38 | - | type pickerLeaseEvent uint8 | |
| 39 | - | ||
| 40 | - | const ( | |
| 41 | - | // pickerLeaseIdle displays the frame normally and leaves presentation | |
| 42 | - | // unchanged. | |
| 43 | - | pickerLeaseIdle pickerLeaseEvent = iota | |
| 44 | - | // pickerLeaseSuppress applies and acknowledges the frame without | |
| 45 | - | // writing it: the client owns the terminal. | |
| 46 | - | pickerLeaseSuppress | |
| 47 | - | // pickerLeaseAcquire displays the frame normally, then displays the | |
| 48 | - | // pending picker frame: the barrier is now applied. | |
| 49 | - | pickerLeaseAcquire | |
| 50 | - | // pickerLeaseRelease displays the authoritative full paint normally, | |
| 51 | - | // then clears the lease. | |
| 52 | - | pickerLeaseRelease | |
| 53 | - | ) | |
| 54 | - | ||
| 55 | - | // pickerLease is the client's presentation identity for one interaction: | |
| 56 | - | // attachment UI generation, interaction ID, displayed revision, and the | |
| 57 | - | // acquisition barrier the snapshot was built against. A generation change | |
| 58 | - | // (reconnect) aborts the lease instead of carrying it into a new | |
| 59 | - | // attachment. | |
| 60 | - | type pickerLease struct { | |
| 61 | - | state pickerLeaseState | |
| 62 | - | generation uint64 | |
| 63 | - | interaction uint64 | |
| 64 | - | revision uint64 | |
| 65 | - | barrierEpoch uint64 | |
| 66 | - | barrierState uint64 | |
| 67 | - | // pending holds the validated model until the barrier is applied; it | |
| 68 | - | // is nil in every other state. | |
| 69 | - | pending *pickerLoop | |
| 70 | - | // releaseAction is the ui-driver action waiting on the release paint | |
| 71 | - | // and releaseLocal reports whether it completes locally (a cancel | |
| 72 | - | // consumed by the client) instead of through the daemon handoff. | |
| 73 | - | releaseAction uint64 | |
| 74 | - | releaseLocal bool | |
| 75 | - | // releaseEpoch/releaseState name the applied output boundary that was | |
| 76 | - | // current when the daemon's close for this interaction was observed. | |
| 77 | - | // Only a full paint accepted after that boundary releases the terminal: | |
| 78 | - | // a Full already in flight before the close is a suppressed artifact, | |
| 79 | - | // not the authoritative restore the release waits for. | |
| 80 | - | releaseEpoch uint64 | |
| 81 | - | releaseState uint64 | |
| 82 | - | // daemonClosed reports whether the serving daemon confirmed the close. | |
| 83 | - | // A client-side retire alone never releases: the daemon sends the close | |
| 84 | - | // before its restore paint, so waiting for it orders the release after | |
| 85 | - | // the close instead of after an in-flight paint. | |
| 86 | - | daemonClosed bool | |
| 87 | - | } | |
| 88 | - | ||
| 89 | - | // active reports whether the lease owns picker presentation. | |
| 90 | - | func (l *pickerLease) active() bool { | |
| 91 | - | return l != nil && l.state != pickerLeaseClosed | |
| 92 | - | } | |
| 93 | - | ||
| 94 | - | // owns reports whether the client has taken the terminal over: only then is | |
| 95 | - | // picker input applied to the displayed model. | |
| 96 | - | func (l *pickerLease) owns() bool { | |
| 97 | - | return l != nil && l.state == pickerLeaseOwned | |
| 98 | - | } | |
| 99 | - | ||
| 100 | - | // releasingFor reports whether this lease is retiring the named | |
| 101 | - | // interaction, which is the only case where a close may extend its drain. | |
| 102 | - | func (l *pickerLease) releasingFor(interaction uint64) bool { | |
| 103 | - | return l != nil && l.state == pickerLeaseReleasing && l.interaction == interaction | |
| 104 | - | } | |
| 105 | - | ||
| 106 | - | // reset returns the lease to closed presentation. | |
| 107 | - | func (l *pickerLease) reset() { | |
| 108 | - | *l = pickerLease{} | |
| 109 | - | } | |
| 110 | - | ||
| 111 | - | // abort ends the lease without a release paint (disconnect, generation | |
| 112 | - | // change, or a superseding interaction). It reports the pending local | |
| 113 | - | // action that can no longer complete. | |
| 114 | - | func (l *pickerLease) abort() (actionID uint64, local bool) { | |
| 115 | - | if l == nil { | |
| 116 | - | return 0, false | |
| 117 | - | } | |
| 118 | - | actionID, local = l.releaseAction, l.releaseLocal | |
| 119 | - | if actionID != 0 && !local { | |
| 120 | - | // A handoff-bound action stays unresolved: the daemon owns its | |
| 121 | - | // outcome and the caller reports the unknown outcome. | |
| 122 | - | actionID, local = 0, false | |
| 123 | - | } | |
| 124 | - | l.reset() | |
| 125 | - | return actionID, local | |
| 126 | - | } | |
| 127 | - | ||
| 128 | - | // pickerBarrier is the daemon output boundary one offer names: the client | |
| 129 | - | // must display admitted output through it before it takes the terminal. | |
| 130 | - | type pickerBarrier struct { | |
| 131 | - | epoch uint64 | |
| 132 | - | state uint64 | |
| 133 | - | } | |
| 134 | - | ||
| 135 | - | // admitSnapshot admits one validated snapshot against the client's applied | |
| 136 | - | // output state. It reports ready=true when the barrier named by the offer is | |
| 137 | - | // already applied and the caller must display the picker frame now. | |
| 138 | - | // superseded is a copy of the previous lease when a new interaction replaces | |
| 139 | - | // it, so the caller can resolve the old pending action without touching the | |
| 140 | - | // newly installed lease. | |
| 141 | - | // | |
| 142 | - | // A snapshot for the current interaction only replaces the pending model | |
| 143 | - | // with a newer source revision, and never after the release started: a | |
| 144 | - | // superseding interaction is a new namespace whose close/full pair cannot | |
| 145 | - | // release the previous one. | |
| 146 | - | func (l *pickerLease) admitSnapshot(snapshot protocol.PickerSnapshot, barrier pickerBarrier, loop *pickerLoop, applied outputApplyState, generation uint64) (ready bool, superseded *pickerLease) { | |
| 147 | - | if l == nil || snapshot.InteractionID == 0 { | |
| 148 | - | return false, nil | |
| 149 | - | } | |
| 150 | - | if l.active() { | |
| 151 | - | if l.interaction == snapshot.InteractionID { | |
| 152 | - | if l.state == pickerLeaseReleasing || snapshot.SourceRevision <= l.revision { | |
| 153 | - | return false, nil | |
| 154 | - | } | |
| 155 | - | } | |
| 156 | - | } | |
| 157 | - | if l.active() && l.interaction != snapshot.InteractionID { | |
| 158 | - | previous := *l | |
| 159 | - | superseded = &previous | |
| 160 | - | } | |
| 161 | - | *l = pickerLease{ | |
| 162 | - | state: pickerLeaseAcquiring, generation: generation, interaction: snapshot.InteractionID, | |
| 163 | - | revision: snapshot.SourceRevision, barrierEpoch: barrier.epoch, barrierState: barrier.state, | |
| 164 | - | pending: loop, | |
| 165 | - | } | |
| 166 | - | if barrierReached(applied, barrier.epoch, barrier.state) { | |
| 167 | - | // The barrier is already displayed: the client owns the terminal | |
| 168 | - | // from this moment, before any later daemon frame can be written. | |
| 169 | - | l.state = pickerLeaseOwned | |
| 170 | - | return true, superseded | |
| 171 | - | } | |
| 172 | - | return false, superseded | |
| 173 | - | } | |
| 174 | - | ||
| 175 | - | // observe reports the presentation decision for one accepted daemon frame. | |
| 176 | - | // Only accepted frames reach it, so the output dependency chain, reset | |
| 177 | - | // handling, and bounded ACK window keep advancing in every state. | |
| 178 | - | func (l *pickerLease) observe(output protocol.Output, applied outputApplyState) pickerLeaseEvent { | |
| 179 | - | if l == nil { | |
| 180 | - | return pickerLeaseIdle | |
| 181 | - | } | |
| 182 | - | switch l.state { | |
| 183 | - | case pickerLeaseAcquiring: | |
| 184 | - | if barrierReached(applied, l.barrierEpoch, l.barrierState) { | |
| 185 | - | l.state = pickerLeaseOwned | |
| 186 | - | return pickerLeaseAcquire | |
| 187 | - | } | |
| 188 | - | return pickerLeaseIdle | |
| 189 | - | case pickerLeaseOwned: | |
| 190 | - | return pickerLeaseSuppress | |
| 191 | - | case pickerLeaseReleasing: | |
| 192 | - | // Only an authoritative full paint accepted after the serving | |
| 193 | - | // daemon's close releases the terminal: an incremental delta, a Full | |
| 194 | - | // already in flight before the close, or any paint seen before the | |
| 195 | - | // close was confirmed may itself be a suppressed artifact. | |
| 196 | - | if l.daemonClosed && output.Full && stateAfter(applied, l.releaseEpoch, l.releaseState) { | |
| 197 | - | return pickerLeaseRelease | |
| 198 | - | } | |
| 199 | - | return pickerLeaseSuppress | |
| 200 | - | } | |
| 201 | - | return pickerLeaseIdle | |
| 202 | - | } | |
| 203 | - | ||
| 204 | - | // stateAfter reports whether the applied output state is strictly newer | |
| 205 | - | // than the named (epoch, state) boundary. An uninitialized state has | |
| 206 | - | // applied only the implicit empty stream: epoch 1, state 0. | |
| 207 | - | func stateAfter(applied outputApplyState, epoch, state uint64) bool { | |
| 208 | - | appliedEpoch, appliedState := applied.epoch, applied.state | |
| 209 | - | if !applied.initialized { | |
| 210 | - | appliedEpoch, appliedState = 1, 0 | |
| 211 | - | } | |
| 212 | - | if appliedEpoch != epoch { | |
| 213 | - | return appliedEpoch > epoch | |
| 214 | - | } | |
| 215 | - | return appliedState > state | |
| 216 | - | } | |
| 217 | - | ||
| 218 | - | // beginRelease retires the interaction named by one close. It reports | |
| 219 | - | // false for a duplicate or foreign close, which must not restart or extend | |
| 220 | - | // the release. fromDaemon marks the serving daemon's own close, which is | |
| 221 | - | // the ordering barrier for the release: a client-side retire waits for it | |
| 222 | - | // before any paint can release the terminal. The pending action completes | |
| 223 | - | // after the release paint: local for a cancel the client consumed, through | |
| 224 | - | // the daemon handoff otherwise. | |
| 225 | - | func (l *pickerLease) beginRelease(interaction, actionID uint64, local, fromDaemon bool, applied outputApplyState) bool { | |
| 226 | - | if l == nil || !l.active() || l.state == pickerLeaseReleasing || l.interaction != interaction { | |
| 227 | - | return false | |
| 228 | - | } | |
| 229 | - | l.state = pickerLeaseReleasing | |
| 230 | - | l.pending = nil | |
| 231 | - | l.releaseAction, l.releaseLocal = actionID, local | |
| 232 | - | l.releaseEpoch, l.releaseState = applied.epoch, applied.state | |
| 233 | - | if !applied.initialized { | |
| 234 | - | l.releaseEpoch, l.releaseState = 1, 0 | |
| 235 | - | } | |
| 236 | - | l.daemonClosed = fromDaemon | |
| 237 | - | return true | |
| 238 | - | } | |
| 239 | - | ||
| 240 | - | // confirmDaemonClose records the serving daemon's close for the releasing | |
| 241 | - | // interaction and re-captures the release boundary at that point. Only | |
| 242 | - | // paints accepted after it can release the terminal. | |
| 243 | - | func (l *pickerLease) confirmDaemonClose(interaction uint64, applied outputApplyState) bool { | |
| 244 | - | if l == nil || l.state != pickerLeaseReleasing || l.interaction != interaction { | |
| 245 | - | return false | |
| 246 | - | } | |
| 247 | - | l.daemonClosed = true | |
| 248 | - | l.releaseEpoch, l.releaseState = applied.epoch, applied.state | |
| 249 | - | if !applied.initialized { | |
| 250 | - | l.releaseEpoch, l.releaseState = 1, 0 | |
| 251 | - | } | |
| 252 | - | return true | |
| 253 | - | } | |
| 254 | - | ||
| 255 | - | // finishRelease clears the lease after the release paint was displayed and | |
| 256 | - | // published. It reports the pending local action to complete. | |
| 257 | - | func (l *pickerLease) finishRelease() (actionID uint64, local bool) { | |
| 258 | - | if l == nil { | |
| 259 | - | return 0, false | |
| 260 | - | } | |
| 261 | - | actionID, local = l.releaseAction, l.releaseLocal | |
| 262 | - | l.reset() | |
| 263 | - | return actionID, local | |
| 264 | - | } | |
| 265 | - | ||
| 266 | - | // barrierReached reports whether the applied output state covers the | |
| 267 | - | // acquisition barrier (epoch, state). An uninitialized state has applied | |
| 268 | - | // only the implicit empty stream: epoch 1, state 0. | |
| 269 | - | func barrierReached(applied outputApplyState, epoch, state uint64) bool { | |
| 270 | - | if !applied.initialized { | |
| 271 | - | return epoch <= 1 && state == 0 | |
| 272 | - | } | |
| 273 | - | if applied.epoch != epoch { | |
| 274 | - | return applied.epoch > epoch | |
| 275 | - | } | |
| 276 | - | return applied.state >= state | |
| 277 | - | } | |
| @@ -1,277 +0,0 @@ | |||||
| 1 | - | package client | |||
| 2 | - | ||||
| 3 | - | import "github.com/bnema/vev/internal/protocol" | |||
| 4 | - | ||||
| 5 | - | // This file owns the client-side presentation lease for one client-picker | |||
| 6 | - | // interaction. The daemon keeps mutation authority (it resolves opaque | |||
| 7 | - | // row keys and performs the handoff); the lease decides who writes the | |||
| 8 | - | // terminal: while it is acquiring, owned, or releasing the client | |||
| 9 | - | // consumes picker input and never lets a daemon paint overwrite the modal, | |||
| 10 | - | // yet every accepted daemon frame is still applied to the output shadow and | |||
| 11 | - | // acknowledged inside the bounded window. | |||
| 12 | - | // | |||
| 13 | - | // The lease is value-oriented and owns no locks: the attach loop is its | |||
| 14 | - | // only caller and already owns the terminal writer. | |||
| 15 | - | ||||
| 16 | - | // pickerLeaseState is the client-side presentation state of one | |||
| 17 | - | // client-picker interaction. | |||
| 18 | - | type pickerLeaseState uint8 | |||
| 19 | - | ||||
| 20 | - | const ( | |||
| 21 | - | // pickerLeaseClosed displays daemon output normally. | |||
| 22 | - | pickerLeaseClosed pickerLeaseState = iota | |||
| 23 | - | // pickerLeaseAcquiring validated a snapshot whose acquisition barrier | |||
| 24 | - | // (epoch, state) is not applied yet. Daemon frames up to that barrier | |||
| 25 | - | // are still displayed; the picker frame is not. | |||
| 26 | - | pickerLeaseAcquiring | |||
| 27 | - | // pickerLeaseOwned displays the picker frame and consumes picker | |||
| 28 | - | // input. Daemon frames are applied and acknowledged but never written. | |||
| 29 | - | pickerLeaseOwned | |||
| 30 | - | // pickerLeaseReleasing retired the interaction on PickerClose. The | |||
| 31 | - | // picker frame stays until one authoritative full paint arrives; then | |||
| 32 | - | // the client releases the terminal and resumes normal input. | |||
| 33 | - | pickerLeaseReleasing | |||
| 34 | - | ) | |||
| 35 | - | ||||
| 36 | - | // pickerLeaseEvent reports the presentation transition one accepted daemon | |||
| 37 | - | // frame triggers. | |||
| 38 | - | type pickerLeaseEvent uint8 | |||
| 39 | - | ||||
| 40 | - | const ( | |||
| 41 | - | // pickerLeaseIdle displays the frame normally and leaves presentation | |||
| 42 | - | // unchanged. | |||
| 43 | - | pickerLeaseIdle pickerLeaseEvent = iota | |||
| 44 | - | // pickerLeaseSuppress applies and acknowledges the frame without | |||
| 45 | - | // writing it: the client owns the terminal. | |||
| 46 | - | pickerLeaseSuppress | |||
| 47 | - | // pickerLeaseAcquire displays the frame normally, then displays the | |||
| 48 | - | // pending picker frame: the barrier is now applied. | |||
| 49 | - | pickerLeaseAcquire | |||
| 50 | - | // pickerLeaseRelease displays the authoritative full paint normally, | |||
| 51 | - | // then clears the lease. | |||
| 52 | - | pickerLeaseRelease | |||
| 53 | - | ) | |||
| 54 | - | ||||
| 55 | - | // pickerLease is the client's presentation identity for one interaction: | |||
| 56 | - | // attachment UI generation, interaction ID, displayed revision, and the | |||
| 57 | - | // acquisition barrier the snapshot was built against. A generation change | |||
| 58 | - | // (reconnect) aborts the lease instead of carrying it into a new | |||
| 59 | - | // attachment. | |||
| 60 | - | type pickerLease struct { | |||
| 61 | - | state pickerLeaseState | |||
| 62 | - | generation uint64 | |||
| 63 | - | interaction uint64 | |||
| 64 | - | revision uint64 | |||
| 65 | - | barrierEpoch uint64 | |||
| 66 | - | barrierState uint64 | |||
| 67 | - | // pending holds the validated model until the barrier is applied; it | |||
| 68 | - | // is nil in every other state. | |||
| 69 | - | pending *pickerLoop | |||
| 70 | - | // releaseAction is the ui-driver action waiting on the release paint | |||
| 71 | - | // and releaseLocal reports whether it completes locally (a cancel | |||
| 72 | - | // consumed by the client) instead of through the daemon handoff. | |||
| 73 | - | releaseAction uint64 | |||
| 74 | - | releaseLocal bool | |||
| 75 | - | // releaseEpoch/releaseState name the applied output boundary that was | |||
| 76 | - | // current when the daemon's close for this interaction was observed. | |||
| 77 | - | // Only a full paint accepted after that boundary releases the terminal: | |||
| 78 | - | // a Full already in flight before the close is a suppressed artifact, | |||
| 79 | - | // not the authoritative restore the release waits for. | |||
| 80 | - | releaseEpoch uint64 | |||
| 81 | - | releaseState uint64 | |||
| 82 | - | // daemonClosed reports whether the serving daemon confirmed the close. | |||
| 83 | - | // A client-side retire alone never releases: the daemon sends the close | |||
| 84 | - | // before its restore paint, so waiting for it orders the release after | |||
| 85 | - | // the close instead of after an in-flight paint. | |||
| 86 | - | daemonClosed bool | |||
| 87 | - | } | |||
| 88 | - | ||||
| 89 | - | // active reports whether the lease owns picker presentation. | |||
| 90 | - | func (l *pickerLease) active() bool { | |||
| 91 | - | return l != nil && l.state != pickerLeaseClosed | |||
| 92 | - | } | |||
| 93 | - | ||||
| 94 | - | // owns reports whether the client has taken the terminal over: only then is | |||
| 95 | - | // picker input applied to the displayed model. | |||
| 96 | - | func (l *pickerLease) owns() bool { | |||
| 97 | - | return l != nil && l.state == pickerLeaseOwned | |||
| 98 | - | } | |||
| 99 | - | ||||
| 100 | - | // releasingFor reports whether this lease is retiring the named | |||
| 101 | - | // interaction, which is the only case where a close may extend its drain. | |||
| 102 | - | func (l *pickerLease) releasingFor(interaction uint64) bool { | |||
| 103 | - | return l != nil && l.state == pickerLeaseReleasing && l.interaction == interaction | |||
| 104 | - | } | |||
| 105 | - | ||||
| 106 | - | // reset returns the lease to closed presentation. | |||
| 107 | - | func (l *pickerLease) reset() { | |||
| 108 | - | *l = pickerLease{} | |||
| 109 | - | } | |||
| 110 | - | ||||
| 111 | - | // abort ends the lease without a release paint (disconnect, generation | |||
| 112 | - | // change, or a superseding interaction). It reports the pending local | |||
| 113 | - | // action that can no longer complete. | |||
| 114 | - | func (l *pickerLease) abort() (actionID uint64, local bool) { | |||
| 115 | - | if l == nil { | |||
| 116 | - | return 0, false | |||
| 117 | - | } | |||
| 118 | - | actionID, local = l.releaseAction, l.releaseLocal | |||
| 119 | - | if actionID != 0 && !local { | |||
| 120 | - | // A handoff-bound action stays unresolved: the daemon owns its | |||
| 121 | - | // outcome and the caller reports the unknown outcome. | |||
| 122 | - | actionID, local = 0, false | |||
| 123 | - | } | |||
| 124 | - | l.reset() | |||
| 125 | - | return actionID, local | |||
| 126 | - | } | |||
| 127 | - | ||||
| 128 | - | // pickerBarrier is the daemon output boundary one offer names: the client | |||
| 129 | - | // must display admitted output through it before it takes the terminal. | |||
| 130 | - | type pickerBarrier struct { | |||
| 131 | - | epoch uint64 | |||
| 132 | - | state uint64 | |||
| 133 | - | } | |||
| 134 | - | ||||
| 135 | - | // admitSnapshot admits one validated snapshot against the client's applied | |||
| 136 | - | // output state. It reports ready=true when the barrier named by the offer is | |||
| 137 | - | // already applied and the caller must display the picker frame now. | |||
| 138 | - | // superseded is a copy of the previous lease when a new interaction replaces | |||
| 139 | - | // it, so the caller can resolve the old pending action without touching the | |||
| 140 | - | // newly installed lease. | |||
| 141 | - | // | |||
| 142 | - | // A snapshot for the current interaction only replaces the pending model | |||
| 143 | - | // with a newer source revision, and never after the release started: a | |||
| 144 | - | // superseding interaction is a new namespace whose close/full pair cannot | |||
| 145 | - | // release the previous one. | |||
| 146 | - | func (l *pickerLease) admitSnapshot(snapshot protocol.PickerSnapshot, barrier pickerBarrier, loop *pickerLoop, applied outputApplyState, generation uint64) (ready bool, superseded *pickerLease) { | |||
| 147 | - | if l == nil || snapshot.InteractionID == 0 { | |||
| 148 | - | return false, nil | |||
| 149 | - | } | |||
| 150 | - | if l.active() { | |||
| 151 | - | if l.interaction == snapshot.InteractionID { | |||
| 152 | - | if l.state == pickerLeaseReleasing || snapshot.SourceRevision <= l.revision { | |||
| 153 | - | return false, nil | |||
| 154 | - | } | |||
| 155 | - | } | |||
| 156 | - | } | |||
| 157 | - | if l.active() && l.interaction != snapshot.InteractionID { | |||
| 158 | - | previous := *l | |||
| 159 | - | superseded = &previous | |||
| 160 | - | } | |||
| 161 | - | *l = pickerLease{ | |||
| 162 | - | state: pickerLeaseAcquiring, generation: generation, interaction: snapshot.InteractionID, | |||
| 163 | - | revision: snapshot.SourceRevision, barrierEpoch: barrier.epoch, barrierState: barrier.state, | |||
| 164 | - | pending: loop, | |||
| 165 | - | } | |||
| 166 | - | if barrierReached(applied, barrier.epoch, barrier.state) { | |||
| 167 | - | // The barrier is already displayed: the client owns the terminal | |||
| 168 | - | // from this moment, before any later daemon frame can be written. | |||
| 169 | - | l.state = pickerLeaseOwned | |||
| 170 | - | return true, superseded | |||
| 171 | - | } | |||
| 172 | - | return false, superseded | |||
| 173 | - | } | |||
| 174 | - | ||||
| 175 | - | // observe reports the presentation decision for one accepted daemon frame. | |||
| 176 | - | // Only accepted frames reach it, so the output dependency chain, reset | |||
| 177 | - | // handling, and bounded ACK window keep advancing in every state. | |||
| 178 | - | func (l *pickerLease) observe(output protocol.Output, applied outputApplyState) pickerLeaseEvent { | |||
| 179 | - | if l == nil { | |||
| 180 | - | return pickerLeaseIdle | |||
| 181 | - | } | |||
| 182 | - | switch l.state { | |||
| 183 | - | case pickerLeaseAcquiring: | |||
| 184 | - | if barrierReached(applied, l.barrierEpoch, l.barrierState) { | |||
| 185 | - | l.state = pickerLeaseOwned | |||
| 186 | - | return pickerLeaseAcquire | |||
| 187 | - | } | |||
| 188 | - | return pickerLeaseIdle | |||
| 189 | - | case pickerLeaseOwned: | |||
| 190 | - | return pickerLeaseSuppress | |||
| 191 | - | case pickerLeaseReleasing: | |||
| 192 | - | // Only an authoritative full paint accepted after the serving | |||
| 193 | - | // daemon's close releases the terminal: an incremental delta, a Full | |||
| 194 | - | // already in flight before the close, or any paint seen before the | |||
| 195 | - | // close was confirmed may itself be a suppressed artifact. | |||
| 196 | - | if l.daemonClosed && output.Full && stateAfter(applied, l.releaseEpoch, l.releaseState) { | |||
| 197 | - | return pickerLeaseRelease | |||
| 198 | - | } | |||
| 199 | - | return pickerLeaseSuppress | |||
| 200 | - | } | |||
| 201 | - | return pickerLeaseIdle | |||
| 202 | - | } | |||
| 203 | - | ||||
| 204 | - | // stateAfter reports whether the applied output state is strictly newer | |||
| 205 | - | // than the named (epoch, state) boundary. An uninitialized state has | |||
| 206 | - | // applied only the implicit empty stream: epoch 1, state 0. | |||
| 207 | - | func stateAfter(applied outputApplyState, epoch, state uint64) bool { | |||
| 208 | - | appliedEpoch, appliedState := applied.epoch, applied.state | |||
| 209 | - | if !applied.initialized { | |||
| 210 | - | appliedEpoch, appliedState = 1, 0 | |||
| 211 | - | } | |||
| 212 | - | if appliedEpoch != epoch { | |||
| 213 | - | return appliedEpoch > epoch | |||
| 214 | - | } | |||
| 215 | - | return appliedState > state | |||
| 216 | - | } | |||
| 217 | - | ||||
| 218 | - | // beginRelease retires the interaction named by one close. It reports | |||
| 219 | - | // false for a duplicate or foreign close, which must not restart or extend | |||
| 220 | - | // the release. fromDaemon marks the serving daemon's own close, which is | |||
| 221 | - | // the ordering barrier for the release: a client-side retire waits for it | |||
| 222 | - | // before any paint can release the terminal. The pending action completes | |||
| 223 | - | // after the release paint: local for a cancel the client consumed, through | |||
| 224 | - | // the daemon handoff otherwise. | |||
| 225 | - | func (l *pickerLease) beginRelease(interaction, actionID uint64, local, fromDaemon bool, applied outputApplyState) bool { | |||
| 226 | - | if l == nil || !l.active() || l.state == pickerLeaseReleasing || l.interaction != interaction { | |||
| 227 | - | return false | |||
| 228 | - | } | |||
| 229 | - | l.state = pickerLeaseReleasing | |||
| 230 | - | l.pending = nil | |||
| 231 | - | l.releaseAction, l.releaseLocal = actionID, local | |||
| 232 | - | l.releaseEpoch, l.releaseState = applied.epoch, applied.state | |||
| 233 | - | if !applied.initialized { | |||
| 234 | - | l.releaseEpoch, l.releaseState = 1, 0 | |||
| 235 | - | } | |||
| 236 | - | l.daemonClosed = fromDaemon | |||
| 237 | - | return true | |||
| 238 | - | } | |||
| 239 | - | ||||
| 240 | - | // confirmDaemonClose records the serving daemon's close for the releasing | |||
| 241 | - | // interaction and re-captures the release boundary at that point. Only | |||
| 242 | - | // paints accepted after it can release the terminal. | |||
| 243 | - | func (l *pickerLease) confirmDaemonClose(interaction uint64, applied outputApplyState) bool { | |||
| 244 | - | if l == nil || l.state != pickerLeaseReleasing || l.interaction != interaction { | |||
| 245 | - | return false | |||
| 246 | - | } | |||
| 247 | - | l.daemonClosed = true | |||
| 248 | - | l.releaseEpoch, l.releaseState = applied.epoch, applied.state | |||
| 249 | - | if !applied.initialized { | |||
| 250 | - | l.releaseEpoch, l.releaseState = 1, 0 | |||
| 251 | - | } | |||
| 252 | - | return true | |||
| 253 | - | } | |||
| 254 | - | ||||
| 255 | - | // finishRelease clears the lease after the release paint was displayed and | |||
| 256 | - | // published. It reports the pending local action to complete. | |||
| 257 | - | func (l *pickerLease) finishRelease() (actionID uint64, local bool) { | |||
| 258 | - | if l == nil { | |||
| 259 | - | return 0, false | |||
| 260 | - | } | |||
| 261 | - | actionID, local = l.releaseAction, l.releaseLocal | |||
| 262 | - | l.reset() | |||
| 263 | - | return actionID, local | |||
| 264 | - | } | |||
| 265 | - | ||||
| 266 | - | // barrierReached reports whether the applied output state covers the | |||
| 267 | - | // acquisition barrier (epoch, state). An uninitialized state has applied | |||
| 268 | - | // only the implicit empty stream: epoch 1, state 0. | |||
| 269 | - | func barrierReached(applied outputApplyState, epoch, state uint64) bool { | |||
| 270 | - | if !applied.initialized { | |||
| 271 | - | return epoch <= 1 && state == 0 | |||
| 272 | - | } | |||
| 273 | - | if applied.epoch != epoch { | |||
| 274 | - | return applied.epoch > epoch | |||
| 275 | - | } | |||
| 276 | - | return applied.state >= state | |||
| 277 | - | } | |||
D internal/usecase/client/picker_lease_test.go +0 -222
| @@ -1,222 +0,0 @@ | |||
| 1 | - | package client | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "testing" | |
| 5 | - | ||
| 6 | - | "github.com/bnema/vev/internal/protocol" | |
| 7 | - | "github.com/stretchr/testify/require" | |
| 8 | - | ) | |
| 9 | - | ||
| 10 | - | // pickerLeaseTestState builds an applied output state for barrier checks. | |
| 11 | - | func pickerLeaseTestState(epoch, state uint64) outputApplyState { | |
| 12 | - | return outputApplyState{epoch: epoch, state: state, initialized: true} | |
| 13 | - | } | |
| 14 | - | ||
| 15 | - | // pickerLeaseTestSnapshot builds one validated source publication for the | |
| 16 | - | // lease. The acquisition barrier travels in the offer, so it is passed | |
| 17 | - | // separately to admitSnapshot. | |
| 18 | - | func pickerLeaseTestSnapshot(interaction, revision uint64) protocol.PickerSnapshot { | |
| 19 | - | return protocol.PickerSnapshot{ | |
| 20 | - | InteractionID: interaction, SourceID: "serving", SourceRevision: revision, | |
| 21 | - | Status: protocol.PickerSourceOK, | |
| 22 | - | Lines: []protocol.PickerLine{ | |
| 23 | - | {Key: "aa/first", Kind: protocol.PickerLineSession, Label: "first", Focusable: true, Actions: protocol.PickerCanNavigate}, | |
| 24 | - | }, | |
| 25 | - | Cursor: protocol.PickerCursor{Key: "aa/first", Index: 0}, | |
| 26 | - | } | |
| 27 | - | } | |
| 28 | - | ||
| 29 | - | func pickerLeaseTestLoop() *pickerLoop { | |
| 30 | - | return &pickerLoop{interaction: 1} | |
| 31 | - | } | |
| 32 | - | ||
| 33 | - | func TestPickerLeaseAcquiresOnlyAtTheBarrier(t *testing.T) { | |
| 34 | - | tests := []struct { | |
| 35 | - | name string | |
| 36 | - | applied outputApplyState | |
| 37 | - | barrierEpoch uint64 | |
| 38 | - | barrierState uint64 | |
| 39 | - | ready bool | |
| 40 | - | owned bool | |
| 41 | - | }{ | |
| 42 | - | {name: "uninitialized zero barrier is already applied", applied: outputApplyState{}, barrierEpoch: 1, ready: true, owned: true}, | |
| 43 | - | {name: "applied state equals the barrier", applied: pickerLeaseTestState(4, 3), barrierEpoch: 4, barrierState: 3, ready: true, owned: true}, | |
| 44 | - | {name: "applied state passed the barrier", applied: pickerLeaseTestState(4, 5), barrierEpoch: 4, barrierState: 3, ready: true, owned: true}, | |
| 45 | - | {name: "newer epoch passed the barrier", applied: pickerLeaseTestState(6, 1), barrierEpoch: 4, barrierState: 3, ready: true, owned: true}, | |
| 46 | - | {name: "barrier still ahead", applied: pickerLeaseTestState(4, 1), barrierEpoch: 4, barrierState: 3, ready: false, owned: false}, | |
| 47 | - | {name: "barrier in a future epoch", applied: pickerLeaseTestState(4, 9), barrierEpoch: 5, barrierState: 1, ready: false, owned: false}, | |
| 48 | - | } | |
| 49 | - | for _, test := range tests { | |
| 50 | - | t.Run(test.name, func(t *testing.T) { | |
| 51 | - | var lease pickerLease | |
| 52 | - | ready, superseded := lease.admitSnapshot(pickerLeaseTestSnapshot(7, 1), pickerBarrier{epoch: test.barrierEpoch, state: test.barrierState}, pickerLeaseTestLoop(), test.applied, 3) | |
| 53 | - | require.Nil(t, superseded) | |
| 54 | - | require.Equal(t, test.ready, ready) | |
| 55 | - | require.Equal(t, test.owned, lease.state == pickerLeaseOwned) | |
| 56 | - | require.Equal(t, uint64(7), lease.interaction) | |
| 57 | - | require.Equal(t, uint64(3), lease.generation) | |
| 58 | - | }) | |
| 59 | - | } | |
| 60 | - | } | |
| 61 | - | ||
| 62 | - | func TestPickerLeaseAcquiresWhenAHeldBarrierArrives(t *testing.T) { | |
| 63 | - | var lease pickerLease | |
| 64 | - | ready, _ := lease.admitSnapshot(pickerLeaseTestSnapshot(7, 1), pickerBarrier{epoch: 4, state: 2}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1) | |
| 65 | - | require.False(t, ready) | |
| 66 | - | require.Equal(t, pickerLeaseAcquiring, lease.state) | |
| 67 | - | ||
| 68 | - | // A still-short frame keeps the lease acquiring and displays the frame. | |
| 69 | - | event := lease.observe(protocol.Output{Epoch: 4, New: 2, Full: true}, pickerLeaseTestState(4, 1)) | |
| 70 | - | require.Equal(t, pickerLeaseIdle, event) | |
| 71 | - | require.Equal(t, pickerLeaseAcquiring, lease.state) | |
| 72 | - | ||
| 73 | - | // The exact barrier is applied: the frame is displayed first and the | |
| 74 | - | // picker follows it. The pending model stays for the caller to install. | |
| 75 | - | event = lease.observe(protocol.Output{Epoch: 4, New: 3, Base: 2}, pickerLeaseTestState(4, 3)) | |
| 76 | - | require.Equal(t, pickerLeaseAcquire, event) | |
| 77 | - | require.Equal(t, pickerLeaseOwned, lease.state) | |
| 78 | - | require.NotNil(t, lease.pending) | |
| 79 | - | require.Equal(t, uint64(1), lease.pending.interaction) | |
| 80 | - | } | |
| 81 | - | ||
| 82 | - | func TestPickerLeaseSuppressesOwnedOutputAndPublishesBoundary(t *testing.T) { | |
| 83 | - | var lease pickerLease | |
| 84 | - | require.True(t, func() bool { | |
| 85 | - | ready, _ := lease.admitSnapshot(pickerLeaseTestSnapshot(7, 1), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1) | |
| 86 | - | return ready | |
| 87 | - | }()) | |
| 88 | - | ||
| 89 | - | require.Equal(t, pickerLeaseSuppress, lease.observe(protocol.Output{Epoch: 4, New: 2, Base: 1}, pickerLeaseTestState(4, 2))) | |
| 90 | - | require.Equal(t, pickerLeaseSuppress, lease.observe(protocol.Output{Epoch: 5, New: 1, Full: true}, pickerLeaseTestState(5, 1))) | |
| 91 | - | require.Equal(t, pickerLeaseOwned, lease.state) | |
| 92 | - | } | |
| 93 | - | ||
| 94 | - | func TestPickerLeaseReleasesOnlyAfterTheDaemonCloseAndANewerFullPaint(t *testing.T) { | |
| 95 | - | var lease pickerLease | |
| 96 | - | ready, _ := lease.admitSnapshot(pickerLeaseTestSnapshot(7, 4), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 2) | |
| 97 | - | require.True(t, ready) | |
| 98 | - | ||
| 99 | - | // The client cancels: the client-side retire alone never releases, because | |
| 100 | - | // it cannot tell a post-close restore from a paint already in flight. | |
| 101 | - | require.True(t, lease.beginRelease(7, 42, true, false, pickerLeaseTestState(4, 1))) | |
| 102 | - | require.Equal(t, pickerLeaseReleasing, lease.state) | |
| 103 | - | require.Equal(t, pickerLeaseSuppress, lease.observe(protocol.Output{Epoch: 4, New: 2, Base: 1}, pickerLeaseTestState(4, 2))) | |
| 104 | - | require.Equal(t, pickerLeaseSuppress, lease.observe(protocol.Output{Epoch: 5, New: 1, Full: true}, pickerLeaseTestState(5, 1)), | |
| 105 | - | "a Full in flight before the daemon close must not release") | |
| 106 | - | ||
| 107 | - | // The daemon confirms the close at the applied boundary it was rendering: | |
| 108 | - | // only paints accepted after that boundary release the terminal. | |
| 109 | - | require.True(t, lease.confirmDaemonClose(7, pickerLeaseTestState(5, 1))) | |
| 110 | - | require.False(t, lease.confirmDaemonClose(8, pickerLeaseTestState(5, 1)), "a foreign confirmation must not move the boundary") | |
| 111 | - | require.Equal(t, pickerLeaseSuppress, lease.observe(protocol.Output{Epoch: 5, New: 1, Full: true}, pickerLeaseTestState(5, 1)), | |
| 112 | - | "a paint at the close boundary is not newer than it") | |
| 113 | - | require.Equal(t, pickerLeaseRelease, lease.observe(protocol.Output{Epoch: 5, New: 2, Full: true}, pickerLeaseTestState(5, 2))) | |
| 114 | - | ||
| 115 | - | actionID, local := lease.finishRelease() | |
| 116 | - | require.Equal(t, uint64(42), actionID) | |
| 117 | - | require.True(t, local) | |
| 118 | - | require.False(t, lease.active()) | |
| 119 | - | } | |
| 120 | - | ||
| 121 | - | func TestPickerLeaseDaemonCloseReleasesWithoutAClientRetire(t *testing.T) { | |
| 122 | - | var lease pickerLease | |
| 123 | - | ready, _ := lease.admitSnapshot(pickerLeaseTestSnapshot(7, 1), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1) | |
| 124 | - | require.True(t, ready) | |
| 125 | - | ||
| 126 | - | // A daemon-initiated close (a resolved selection) carries its own barrier. | |
| 127 | - | require.True(t, lease.beginRelease(7, 0, false, true, pickerLeaseTestState(4, 1))) | |
| 128 | - | require.Equal(t, pickerLeaseRelease, lease.observe(protocol.Output{Epoch: 5, New: 1, Full: true}, pickerLeaseTestState(5, 1))) | |
| 129 | - | } | |
| 130 | - | ||
| 131 | - | func TestPickerLeaseForeignAndDuplicateCloseNeverRelease(t *testing.T) { | |
| 132 | - | var lease pickerLease | |
| 133 | - | ready, _ := lease.admitSnapshot(pickerLeaseTestSnapshot(7, 1), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1) | |
| 134 | - | require.True(t, ready) | |
| 135 | - | ||
| 136 | - | require.False(t, lease.beginRelease(6, 42, true, false, pickerLeaseTestState(4, 1)), "foreign interaction must not release") | |
| 137 | - | require.False(t, lease.beginRelease(0, 42, true, false, pickerLeaseTestState(4, 1)), "zero interaction must not release") | |
| 138 | - | require.True(t, lease.beginRelease(7, 42, true, false, pickerLeaseTestState(4, 1))) | |
| 139 | - | require.False(t, lease.beginRelease(7, 43, true, false, pickerLeaseTestState(4, 1)), "duplicate close must not restart the release") | |
| 140 | - | require.Equal(t, uint64(42), lease.releaseAction) | |
| 141 | - | } | |
| 142 | - | ||
| 143 | - | func TestPickerLeaseSupersedingSnapshotStartsANewNamespace(t *testing.T) { | |
| 144 | - | var lease pickerLease | |
| 145 | - | ready, _ := lease.admitSnapshot(pickerLeaseTestSnapshot(7, 1), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1) | |
| 146 | - | require.True(t, ready) | |
| 147 | - | require.True(t, lease.beginRelease(7, 42, true, false, pickerLeaseTestState(4, 1))) | |
| 148 | - | ||
| 149 | - | // A superseding interaction cannot be released by the prior close. | |
| 150 | - | ready, superseded := lease.admitSnapshot(pickerLeaseTestSnapshot(8, 1), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1) | |
| 151 | - | require.True(t, ready) | |
| 152 | - | require.NotNil(t, superseded) | |
| 153 | - | require.Equal(t, uint64(7), superseded.interaction) | |
| 154 | - | require.Equal(t, uint64(8), lease.interaction) | |
| 155 | - | require.Equal(t, pickerLeaseOwned, lease.state) | |
| 156 | - | require.False(t, lease.beginRelease(7, 42, true, false, pickerLeaseTestState(4, 1))) | |
| 157 | - | } | |
| 158 | - | ||
| 159 | - | func TestPickerLeaseSameInteractionRevisionRules(t *testing.T) { | |
| 160 | - | var lease pickerLease | |
| 161 | - | ready, _ := lease.admitSnapshot(pickerLeaseTestSnapshot(7, 3), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1) | |
| 162 | - | require.True(t, ready) | |
| 163 | - | ||
| 164 | - | // Duplicate and older revisions never replace the displayed model. | |
| 165 | - | ready, superseded := lease.admitSnapshot(pickerLeaseTestSnapshot(7, 3), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1) | |
| 166 | - | require.False(t, ready) | |
| 167 | - | require.Nil(t, superseded) | |
| 168 | - | ready, superseded = lease.admitSnapshot(pickerLeaseTestSnapshot(7, 2), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1) | |
| 169 | - | require.False(t, ready) | |
| 170 | - | require.Nil(t, superseded) | |
| 171 | - | ||
| 172 | - | // A newer revision refreshes in place. | |
| 173 | - | ready, superseded = lease.admitSnapshot(pickerLeaseTestSnapshot(7, 4), pickerBarrier{epoch: 4, state: 2}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1) | |
| 174 | - | require.False(t, ready) | |
| 175 | - | require.Nil(t, superseded) | |
| 176 | - | require.Equal(t, uint64(4), lease.revision) | |
| 177 | - | ||
| 178 | - | // A newer revision that also moved the barrier to an applied state | |
| 179 | - | // acquires immediately. | |
| 180 | - | ready, _ = lease.admitSnapshot(pickerLeaseTestSnapshot(7, 5), pickerBarrier{epoch: 4, state: 2}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 2), 1) | |
| 181 | - | require.True(t, ready) | |
| 182 | - | ||
| 183 | - | // A release in progress refuses revisions: the close/full pair cannot be | |
| 184 | - | // replaced by a late refresh. | |
| 185 | - | require.True(t, lease.beginRelease(7, 0, false, true, pickerLeaseTestState(4, 2))) | |
| 186 | - | ready, superseded = lease.admitSnapshot(pickerLeaseTestSnapshot(7, 6), pickerBarrier{epoch: 4, state: 2}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 2), 1) | |
| 187 | - | require.False(t, ready) | |
| 188 | - | require.Nil(t, superseded) | |
| 189 | - | } | |
| 190 | - | ||
| 191 | - | func TestPickerLeaseAbortReportsPendingLocalAction(t *testing.T) { | |
| 192 | - | var lease pickerLease | |
| 193 | - | lease.admitSnapshot(pickerLeaseTestSnapshot(7, 1), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1) | |
| 194 | - | require.True(t, lease.beginRelease(7, 42, true, false, pickerLeaseTestState(4, 1))) | |
| 195 | - | ||
| 196 | - | actionID, local := lease.abort() | |
| 197 | - | require.Equal(t, uint64(42), actionID) | |
| 198 | - | require.True(t, local) | |
| 199 | - | require.False(t, lease.active()) | |
| 200 | - | ||
| 201 | - | // A handoff-bound action stays unresolved: the daemon owns its outcome. | |
| 202 | - | lease.admitSnapshot(pickerLeaseTestSnapshot(9, 1), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1) | |
| 203 | - | lease.beginRelease(9, 77, false, false, pickerLeaseTestState(4, 1)) | |
| 204 | - | actionID, local = lease.abort() | |
| 205 | - | require.Zero(t, actionID) | |
| 206 | - | require.False(t, local) | |
| 207 | - | } | |
| 208 | - | ||
| 209 | - | func TestPickerLeaseRejectsUnusableSnapshots(t *testing.T) { | |
| 210 | - | var lease pickerLease | |
| 211 | - | ready, superseded := lease.admitSnapshot(pickerLeaseTestSnapshot(0, 1), pickerBarrier{epoch: 1, state: 0}, pickerLeaseTestLoop(), outputApplyState{}, 1) | |
| 212 | - | require.False(t, ready) | |
| 213 | - | require.Nil(t, superseded) | |
| 214 | - | require.False(t, lease.active()) | |
| 215 | - | } | |
| 216 | - | ||
| 217 | - | func TestPickerLeaseClosedPresentationNeverSuppresses(t *testing.T) { | |
| 218 | - | var lease pickerLease | |
| 219 | - | require.False(t, lease.active()) | |
| 220 | - | require.Equal(t, pickerLeaseIdle, lease.observe(protocol.Output{Epoch: 1, New: 1, Full: true}, pickerLeaseTestState(1, 1))) | |
| 221 | - | require.Equal(t, pickerLeaseIdle, lease.observe(protocol.Output{Epoch: 1, New: 2, Base: 1}, pickerLeaseTestState(1, 2))) | |
| 222 | - | } | |
| @@ -1,222 +0,0 @@ | |||||
| 1 | - | package client | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "testing" | |||
| 5 | - | ||||
| 6 | - | "github.com/bnema/vev/internal/protocol" | |||
| 7 | - | "github.com/stretchr/testify/require" | |||
| 8 | - | ) | |||
| 9 | - | ||||
| 10 | - | // pickerLeaseTestState builds an applied output state for barrier checks. | |||
| 11 | - | func pickerLeaseTestState(epoch, state uint64) outputApplyState { | |||
| 12 | - | return outputApplyState{epoch: epoch, state: state, initialized: true} | |||
| 13 | - | } | |||
| 14 | - | ||||
| 15 | - | // pickerLeaseTestSnapshot builds one validated source publication for the | |||
| 16 | - | // lease. The acquisition barrier travels in the offer, so it is passed | |||
| 17 | - | // separately to admitSnapshot. | |||
| 18 | - | func pickerLeaseTestSnapshot(interaction, revision uint64) protocol.PickerSnapshot { | |||
| 19 | - | return protocol.PickerSnapshot{ | |||
| 20 | - | InteractionID: interaction, SourceID: "serving", SourceRevision: revision, | |||
| 21 | - | Status: protocol.PickerSourceOK, | |||
| 22 | - | Lines: []protocol.PickerLine{ | |||
| 23 | - | {Key: "aa/first", Kind: protocol.PickerLineSession, Label: "first", Focusable: true, Actions: protocol.PickerCanNavigate}, | |||
| 24 | - | }, | |||
| 25 | - | Cursor: protocol.PickerCursor{Key: "aa/first", Index: 0}, | |||
| 26 | - | } | |||
| 27 | - | } | |||
| 28 | - | ||||
| 29 | - | func pickerLeaseTestLoop() *pickerLoop { | |||
| 30 | - | return &pickerLoop{interaction: 1} | |||
| 31 | - | } | |||
| 32 | - | ||||
| 33 | - | func TestPickerLeaseAcquiresOnlyAtTheBarrier(t *testing.T) { | |||
| 34 | - | tests := []struct { | |||
| 35 | - | name string | |||
| 36 | - | applied outputApplyState | |||
| 37 | - | barrierEpoch uint64 | |||
| 38 | - | barrierState uint64 | |||
| 39 | - | ready bool | |||
| 40 | - | owned bool | |||
| 41 | - | }{ | |||
| 42 | - | {name: "uninitialized zero barrier is already applied", applied: outputApplyState{}, barrierEpoch: 1, ready: true, owned: true}, | |||
| 43 | - | {name: "applied state equals the barrier", applied: pickerLeaseTestState(4, 3), barrierEpoch: 4, barrierState: 3, ready: true, owned: true}, | |||
| 44 | - | {name: "applied state passed the barrier", applied: pickerLeaseTestState(4, 5), barrierEpoch: 4, barrierState: 3, ready: true, owned: true}, | |||
| 45 | - | {name: "newer epoch passed the barrier", applied: pickerLeaseTestState(6, 1), barrierEpoch: 4, barrierState: 3, ready: true, owned: true}, | |||
| 46 | - | {name: "barrier still ahead", applied: pickerLeaseTestState(4, 1), barrierEpoch: 4, barrierState: 3, ready: false, owned: false}, | |||
| 47 | - | {name: "barrier in a future epoch", applied: pickerLeaseTestState(4, 9), barrierEpoch: 5, barrierState: 1, ready: false, owned: false}, | |||
| 48 | - | } | |||
| 49 | - | for _, test := range tests { | |||
| 50 | - | t.Run(test.name, func(t *testing.T) { | |||
| 51 | - | var lease pickerLease | |||
| 52 | - | ready, superseded := lease.admitSnapshot(pickerLeaseTestSnapshot(7, 1), pickerBarrier{epoch: test.barrierEpoch, state: test.barrierState}, pickerLeaseTestLoop(), test.applied, 3) | |||
| 53 | - | require.Nil(t, superseded) | |||
| 54 | - | require.Equal(t, test.ready, ready) | |||
| 55 | - | require.Equal(t, test.owned, lease.state == pickerLeaseOwned) | |||
| 56 | - | require.Equal(t, uint64(7), lease.interaction) | |||
| 57 | - | require.Equal(t, uint64(3), lease.generation) | |||
| 58 | - | }) | |||
| 59 | - | } | |||
| 60 | - | } | |||
| 61 | - | ||||
| 62 | - | func TestPickerLeaseAcquiresWhenAHeldBarrierArrives(t *testing.T) { | |||
| 63 | - | var lease pickerLease | |||
| 64 | - | ready, _ := lease.admitSnapshot(pickerLeaseTestSnapshot(7, 1), pickerBarrier{epoch: 4, state: 2}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1) | |||
| 65 | - | require.False(t, ready) | |||
| 66 | - | require.Equal(t, pickerLeaseAcquiring, lease.state) | |||
| 67 | - | ||||
| 68 | - | // A still-short frame keeps the lease acquiring and displays the frame. | |||
| 69 | - | event := lease.observe(protocol.Output{Epoch: 4, New: 2, Full: true}, pickerLeaseTestState(4, 1)) | |||
| 70 | - | require.Equal(t, pickerLeaseIdle, event) | |||
| 71 | - | require.Equal(t, pickerLeaseAcquiring, lease.state) | |||
| 72 | - | ||||
| 73 | - | // The exact barrier is applied: the frame is displayed first and the | |||
| 74 | - | // picker follows it. The pending model stays for the caller to install. | |||
| 75 | - | event = lease.observe(protocol.Output{Epoch: 4, New: 3, Base: 2}, pickerLeaseTestState(4, 3)) | |||
| 76 | - | require.Equal(t, pickerLeaseAcquire, event) | |||
| 77 | - | require.Equal(t, pickerLeaseOwned, lease.state) | |||
| 78 | - | require.NotNil(t, lease.pending) | |||
| 79 | - | require.Equal(t, uint64(1), lease.pending.interaction) | |||
| 80 | - | } | |||
| 81 | - | ||||
| 82 | - | func TestPickerLeaseSuppressesOwnedOutputAndPublishesBoundary(t *testing.T) { | |||
| 83 | - | var lease pickerLease | |||
| 84 | - | require.True(t, func() bool { | |||
| 85 | - | ready, _ := lease.admitSnapshot(pickerLeaseTestSnapshot(7, 1), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1) | |||
| 86 | - | return ready | |||
| 87 | - | }()) | |||
| 88 | - | ||||
| 89 | - | require.Equal(t, pickerLeaseSuppress, lease.observe(protocol.Output{Epoch: 4, New: 2, Base: 1}, pickerLeaseTestState(4, 2))) | |||
| 90 | - | require.Equal(t, pickerLeaseSuppress, lease.observe(protocol.Output{Epoch: 5, New: 1, Full: true}, pickerLeaseTestState(5, 1))) | |||
| 91 | - | require.Equal(t, pickerLeaseOwned, lease.state) | |||
| 92 | - | } | |||
| 93 | - | ||||
| 94 | - | func TestPickerLeaseReleasesOnlyAfterTheDaemonCloseAndANewerFullPaint(t *testing.T) { | |||
| 95 | - | var lease pickerLease | |||
| 96 | - | ready, _ := lease.admitSnapshot(pickerLeaseTestSnapshot(7, 4), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 2) | |||
| 97 | - | require.True(t, ready) | |||
| 98 | - | ||||
| 99 | - | // The client cancels: the client-side retire alone never releases, because | |||
| 100 | - | // it cannot tell a post-close restore from a paint already in flight. | |||
| 101 | - | require.True(t, lease.beginRelease(7, 42, true, false, pickerLeaseTestState(4, 1))) | |||
| 102 | - | require.Equal(t, pickerLeaseReleasing, lease.state) | |||
| 103 | - | require.Equal(t, pickerLeaseSuppress, lease.observe(protocol.Output{Epoch: 4, New: 2, Base: 1}, pickerLeaseTestState(4, 2))) | |||
| 104 | - | require.Equal(t, pickerLeaseSuppress, lease.observe(protocol.Output{Epoch: 5, New: 1, Full: true}, pickerLeaseTestState(5, 1)), | |||
| 105 | - | "a Full in flight before the daemon close must not release") | |||
| 106 | - | ||||
| 107 | - | // The daemon confirms the close at the applied boundary it was rendering: | |||
| 108 | - | // only paints accepted after that boundary release the terminal. | |||
| 109 | - | require.True(t, lease.confirmDaemonClose(7, pickerLeaseTestState(5, 1))) | |||
| 110 | - | require.False(t, lease.confirmDaemonClose(8, pickerLeaseTestState(5, 1)), "a foreign confirmation must not move the boundary") | |||
| 111 | - | require.Equal(t, pickerLeaseSuppress, lease.observe(protocol.Output{Epoch: 5, New: 1, Full: true}, pickerLeaseTestState(5, 1)), | |||
| 112 | - | "a paint at the close boundary is not newer than it") | |||
| 113 | - | require.Equal(t, pickerLeaseRelease, lease.observe(protocol.Output{Epoch: 5, New: 2, Full: true}, pickerLeaseTestState(5, 2))) | |||
| 114 | - | ||||
| 115 | - | actionID, local := lease.finishRelease() | |||
| 116 | - | require.Equal(t, uint64(42), actionID) | |||
| 117 | - | require.True(t, local) | |||
| 118 | - | require.False(t, lease.active()) | |||
| 119 | - | } | |||
| 120 | - | ||||
| 121 | - | func TestPickerLeaseDaemonCloseReleasesWithoutAClientRetire(t *testing.T) { | |||
| 122 | - | var lease pickerLease | |||
| 123 | - | ready, _ := lease.admitSnapshot(pickerLeaseTestSnapshot(7, 1), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1) | |||
| 124 | - | require.True(t, ready) | |||
| 125 | - | ||||
| 126 | - | // A daemon-initiated close (a resolved selection) carries its own barrier. | |||
| 127 | - | require.True(t, lease.beginRelease(7, 0, false, true, pickerLeaseTestState(4, 1))) | |||
| 128 | - | require.Equal(t, pickerLeaseRelease, lease.observe(protocol.Output{Epoch: 5, New: 1, Full: true}, pickerLeaseTestState(5, 1))) | |||
| 129 | - | } | |||
| 130 | - | ||||
| 131 | - | func TestPickerLeaseForeignAndDuplicateCloseNeverRelease(t *testing.T) { | |||
| 132 | - | var lease pickerLease | |||
| 133 | - | ready, _ := lease.admitSnapshot(pickerLeaseTestSnapshot(7, 1), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1) | |||
| 134 | - | require.True(t, ready) | |||
| 135 | - | ||||
| 136 | - | require.False(t, lease.beginRelease(6, 42, true, false, pickerLeaseTestState(4, 1)), "foreign interaction must not release") | |||
| 137 | - | require.False(t, lease.beginRelease(0, 42, true, false, pickerLeaseTestState(4, 1)), "zero interaction must not release") | |||
| 138 | - | require.True(t, lease.beginRelease(7, 42, true, false, pickerLeaseTestState(4, 1))) | |||
| 139 | - | require.False(t, lease.beginRelease(7, 43, true, false, pickerLeaseTestState(4, 1)), "duplicate close must not restart the release") | |||
| 140 | - | require.Equal(t, uint64(42), lease.releaseAction) | |||
| 141 | - | } | |||
| 142 | - | ||||
| 143 | - | func TestPickerLeaseSupersedingSnapshotStartsANewNamespace(t *testing.T) { | |||
| 144 | - | var lease pickerLease | |||
| 145 | - | ready, _ := lease.admitSnapshot(pickerLeaseTestSnapshot(7, 1), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1) | |||
| 146 | - | require.True(t, ready) | |||
| 147 | - | require.True(t, lease.beginRelease(7, 42, true, false, pickerLeaseTestState(4, 1))) | |||
| 148 | - | ||||
| 149 | - | // A superseding interaction cannot be released by the prior close. | |||
| 150 | - | ready, superseded := lease.admitSnapshot(pickerLeaseTestSnapshot(8, 1), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1) | |||
| 151 | - | require.True(t, ready) | |||
| 152 | - | require.NotNil(t, superseded) | |||
| 153 | - | require.Equal(t, uint64(7), superseded.interaction) | |||
| 154 | - | require.Equal(t, uint64(8), lease.interaction) | |||
| 155 | - | require.Equal(t, pickerLeaseOwned, lease.state) | |||
| 156 | - | require.False(t, lease.beginRelease(7, 42, true, false, pickerLeaseTestState(4, 1))) | |||
| 157 | - | } | |||
| 158 | - | ||||
| 159 | - | func TestPickerLeaseSameInteractionRevisionRules(t *testing.T) { | |||
| 160 | - | var lease pickerLease | |||
| 161 | - | ready, _ := lease.admitSnapshot(pickerLeaseTestSnapshot(7, 3), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1) | |||
| 162 | - | require.True(t, ready) | |||
| 163 | - | ||||
| 164 | - | // Duplicate and older revisions never replace the displayed model. | |||
| 165 | - | ready, superseded := lease.admitSnapshot(pickerLeaseTestSnapshot(7, 3), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1) | |||
| 166 | - | require.False(t, ready) | |||
| 167 | - | require.Nil(t, superseded) | |||
| 168 | - | ready, superseded = lease.admitSnapshot(pickerLeaseTestSnapshot(7, 2), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1) | |||
| 169 | - | require.False(t, ready) | |||
| 170 | - | require.Nil(t, superseded) | |||
| 171 | - | ||||
| 172 | - | // A newer revision refreshes in place. | |||
| 173 | - | ready, superseded = lease.admitSnapshot(pickerLeaseTestSnapshot(7, 4), pickerBarrier{epoch: 4, state: 2}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1) | |||
| 174 | - | require.False(t, ready) | |||
| 175 | - | require.Nil(t, superseded) | |||
| 176 | - | require.Equal(t, uint64(4), lease.revision) | |||
| 177 | - | ||||
| 178 | - | // A newer revision that also moved the barrier to an applied state | |||
| 179 | - | // acquires immediately. | |||
| 180 | - | ready, _ = lease.admitSnapshot(pickerLeaseTestSnapshot(7, 5), pickerBarrier{epoch: 4, state: 2}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 2), 1) | |||
| 181 | - | require.True(t, ready) | |||
| 182 | - | ||||
| 183 | - | // A release in progress refuses revisions: the close/full pair cannot be | |||
| 184 | - | // replaced by a late refresh. | |||
| 185 | - | require.True(t, lease.beginRelease(7, 0, false, true, pickerLeaseTestState(4, 2))) | |||
| 186 | - | ready, superseded = lease.admitSnapshot(pickerLeaseTestSnapshot(7, 6), pickerBarrier{epoch: 4, state: 2}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 2), 1) | |||
| 187 | - | require.False(t, ready) | |||
| 188 | - | require.Nil(t, superseded) | |||
| 189 | - | } | |||
| 190 | - | ||||
| 191 | - | func TestPickerLeaseAbortReportsPendingLocalAction(t *testing.T) { | |||
| 192 | - | var lease pickerLease | |||
| 193 | - | lease.admitSnapshot(pickerLeaseTestSnapshot(7, 1), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1) | |||
| 194 | - | require.True(t, lease.beginRelease(7, 42, true, false, pickerLeaseTestState(4, 1))) | |||
| 195 | - | ||||
| 196 | - | actionID, local := lease.abort() | |||
| 197 | - | require.Equal(t, uint64(42), actionID) | |||
| 198 | - | require.True(t, local) | |||
| 199 | - | require.False(t, lease.active()) | |||
| 200 | - | ||||
| 201 | - | // A handoff-bound action stays unresolved: the daemon owns its outcome. | |||
| 202 | - | lease.admitSnapshot(pickerLeaseTestSnapshot(9, 1), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1) | |||
| 203 | - | lease.beginRelease(9, 77, false, false, pickerLeaseTestState(4, 1)) | |||
| 204 | - | actionID, local = lease.abort() | |||
| 205 | - | require.Zero(t, actionID) | |||
| 206 | - | require.False(t, local) | |||
| 207 | - | } | |||
| 208 | - | ||||
| 209 | - | func TestPickerLeaseRejectsUnusableSnapshots(t *testing.T) { | |||
| 210 | - | var lease pickerLease | |||
| 211 | - | ready, superseded := lease.admitSnapshot(pickerLeaseTestSnapshot(0, 1), pickerBarrier{epoch: 1, state: 0}, pickerLeaseTestLoop(), outputApplyState{}, 1) | |||
| 212 | - | require.False(t, ready) | |||
| 213 | - | require.Nil(t, superseded) | |||
| 214 | - | require.False(t, lease.active()) | |||
| 215 | - | } | |||
| 216 | - | ||||
| 217 | - | func TestPickerLeaseClosedPresentationNeverSuppresses(t *testing.T) { | |||
| 218 | - | var lease pickerLease | |||
| 219 | - | require.False(t, lease.active()) | |||
| 220 | - | require.Equal(t, pickerLeaseIdle, lease.observe(protocol.Output{Epoch: 1, New: 1, Full: true}, pickerLeaseTestState(1, 1))) | |||
| 221 | - | require.Equal(t, pickerLeaseIdle, lease.observe(protocol.Output{Epoch: 1, New: 2, Base: 1}, pickerLeaseTestState(1, 2))) | |||
| 222 | - | } | |||
A internal/usecase/client/preview_manager.go +93 -0
| @@ -0,0 +1,93 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/vev/internal/domain" | |
| 5 | + | "github.com/bnema/vev/internal/ports" | |
| 6 | + | "github.com/bnema/vev/internal/protocol" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // previewManager is driven only by the supervisor's serialized ready loop. | |
| 10 | + | // generation never decreases, including across broker reconnections. | |
| 11 | + | type pickerPreviewHost interface { | |
| 12 | + | PreviewRequest(ports.BrokerConnectionID, ports.BrokerStreamID, domain.Size) (ports.BrokerOpenStreamRequest, protocol.RemotePreviewRequest, bool) | |
| 13 | + | SetPreview(protocol.RemotePreview) | |
| 14 | + | ClearPreview() | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | type previewManager struct { | |
| 18 | + | generation ports.BrokerPreviewGeneration | |
| 19 | + | sub ports.BrokerPreviewSubscription | |
| 20 | + | request ports.BrokerPreviewRequest | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func (m *previewManager) close(host pickerHost) { | |
| 24 | + | picker, _ := host.(pickerPreviewHost) | |
| 25 | + | m.generation++ // invalidate a publication already selected by the runtime | |
| 26 | + | if m.sub != nil { | |
| 27 | + | m.sub.Close() | |
| 28 | + | m.sub = nil | |
| 29 | + | } | |
| 30 | + | m.request = ports.BrokerPreviewRequest{} | |
| 31 | + | if picker != nil { | |
| 32 | + | picker.ClearPreview() | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func (m *previewManager) refresh(service ports.BrokerService, host pickerHost, size domain.Size) { | |
| 37 | + | picker, ok := host.(pickerPreviewHost) | |
| 38 | + | if service == nil || !ok { | |
| 39 | + | m.close(host) | |
| 40 | + | return | |
| 41 | + | } | |
| 42 | + | // Probe selection eligibility before consuming a logical stream ID. The | |
| 43 | + | // controller only uses the provisional non-zero ID for validation. | |
| 44 | + | _, preview, ok := picker.PreviewRequest(service.ConnectionID(), 1, size) | |
| 45 | + | if !ok { | |
| 46 | + | m.close(host) | |
| 47 | + | return | |
| 48 | + | } | |
| 49 | + | if m.sub != nil && m.request.Preview.Target == preview.Target && m.request.Preview.Width == preview.Width && m.request.Preview.Height == preview.Height { | |
| 50 | + | return | |
| 51 | + | } | |
| 52 | + | stream, err := service.NextStreamID() | |
| 53 | + | if err != nil { | |
| 54 | + | m.close(host) | |
| 55 | + | return | |
| 56 | + | } | |
| 57 | + | route, preview, ok := picker.PreviewRequest(service.ConnectionID(), stream, size) | |
| 58 | + | if !ok { | |
| 59 | + | m.close(host) | |
| 60 | + | return | |
| 61 | + | } | |
| 62 | + | m.close(host) // contract requires old observation closed before replacement | |
| 63 | + | m.generation++ | |
| 64 | + | req := ports.BrokerPreviewRequest{Epoch: route.Epoch, Connection: service.ConnectionID(), Generation: m.generation, Route: route, Preview: preview} | |
| 65 | + | sub, err := service.SubscribePreview(req) | |
| 66 | + | if err != nil || supervisorNil(sub) { | |
| 67 | + | return | |
| 68 | + | } | |
| 69 | + | m.request, m.sub = req, sub | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func (m *previewManager) changed() <-chan struct{} { | |
| 73 | + | if m.sub == nil { | |
| 74 | + | return nil | |
| 75 | + | } | |
| 76 | + | return m.sub.Changed() | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func (m *previewManager) publish(host pickerHost) bool { | |
| 80 | + | picker, ok := host.(pickerPreviewHost) | |
| 81 | + | if m.sub == nil || !ok { | |
| 82 | + | return false | |
| 83 | + | } | |
| 84 | + | publication := m.sub.Latest() | |
| 85 | + | if publication.Validate() != nil || publication.Err != nil || | |
| 86 | + | publication.Epoch != m.request.Epoch || publication.Connection != m.request.Connection || | |
| 87 | + | publication.Generation != m.request.Generation || publication.Target != m.request.Preview.Target || | |
| 88 | + | publication.Width != m.request.Preview.Width || publication.Height != m.request.Preview.Height { | |
| 89 | + | return false | |
| 90 | + | } | |
| 91 | + | picker.SetPreview(publication.Preview) | |
| 92 | + | return true | |
| 93 | + | } | |
| @@ -0,0 +1,93 @@ | |||||
| 1 | + | package client | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/bnema/vev/internal/domain" | |||
| 5 | + | "github.com/bnema/vev/internal/ports" | |||
| 6 | + | "github.com/bnema/vev/internal/protocol" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // previewManager is driven only by the supervisor's serialized ready loop. | |||
| 10 | + | // generation never decreases, including across broker reconnections. | |||
| 11 | + | type pickerPreviewHost interface { | |||
| 12 | + | PreviewRequest(ports.BrokerConnectionID, ports.BrokerStreamID, domain.Size) (ports.BrokerOpenStreamRequest, protocol.RemotePreviewRequest, bool) | |||
| 13 | + | SetPreview(protocol.RemotePreview) | |||
| 14 | + | ClearPreview() | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | type previewManager struct { | |||
| 18 | + | generation ports.BrokerPreviewGeneration | |||
| 19 | + | sub ports.BrokerPreviewSubscription | |||
| 20 | + | request ports.BrokerPreviewRequest | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func (m *previewManager) close(host pickerHost) { | |||
| 24 | + | picker, _ := host.(pickerPreviewHost) | |||
| 25 | + | m.generation++ // invalidate a publication already selected by the runtime | |||
| 26 | + | if m.sub != nil { | |||
| 27 | + | m.sub.Close() | |||
| 28 | + | m.sub = nil | |||
| 29 | + | } | |||
| 30 | + | m.request = ports.BrokerPreviewRequest{} | |||
| 31 | + | if picker != nil { | |||
| 32 | + | picker.ClearPreview() | |||
| 33 | + | } | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func (m *previewManager) refresh(service ports.BrokerService, host pickerHost, size domain.Size) { | |||
| 37 | + | picker, ok := host.(pickerPreviewHost) | |||
| 38 | + | if service == nil || !ok { | |||
| 39 | + | m.close(host) | |||
| 40 | + | return | |||
| 41 | + | } | |||
| 42 | + | // Probe selection eligibility before consuming a logical stream ID. The | |||
| 43 | + | // controller only uses the provisional non-zero ID for validation. | |||
| 44 | + | _, preview, ok := picker.PreviewRequest(service.ConnectionID(), 1, size) | |||
| 45 | + | if !ok { | |||
| 46 | + | m.close(host) | |||
| 47 | + | return | |||
| 48 | + | } | |||
| 49 | + | if m.sub != nil && m.request.Preview.Target == preview.Target && m.request.Preview.Width == preview.Width && m.request.Preview.Height == preview.Height { | |||
| 50 | + | return | |||
| 51 | + | } | |||
| 52 | + | stream, err := service.NextStreamID() | |||
| 53 | + | if err != nil { | |||
| 54 | + | m.close(host) | |||
| 55 | + | return | |||
| 56 | + | } | |||
| 57 | + | route, preview, ok := picker.PreviewRequest(service.ConnectionID(), stream, size) | |||
| 58 | + | if !ok { | |||
| 59 | + | m.close(host) | |||
| 60 | + | return | |||
| 61 | + | } | |||
| 62 | + | m.close(host) // contract requires old observation closed before replacement | |||
| 63 | + | m.generation++ | |||
| 64 | + | req := ports.BrokerPreviewRequest{Epoch: route.Epoch, Connection: service.ConnectionID(), Generation: m.generation, Route: route, Preview: preview} | |||
| 65 | + | sub, err := service.SubscribePreview(req) | |||
| 66 | + | if err != nil || supervisorNil(sub) { | |||
| 67 | + | return | |||
| 68 | + | } | |||
| 69 | + | m.request, m.sub = req, sub | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | func (m *previewManager) changed() <-chan struct{} { | |||
| 73 | + | if m.sub == nil { | |||
| 74 | + | return nil | |||
| 75 | + | } | |||
| 76 | + | return m.sub.Changed() | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func (m *previewManager) publish(host pickerHost) bool { | |||
| 80 | + | picker, ok := host.(pickerPreviewHost) | |||
| 81 | + | if m.sub == nil || !ok { | |||
| 82 | + | return false | |||
| 83 | + | } | |||
| 84 | + | publication := m.sub.Latest() | |||
| 85 | + | if publication.Validate() != nil || publication.Err != nil || | |||
| 86 | + | publication.Epoch != m.request.Epoch || publication.Connection != m.request.Connection || | |||
| 87 | + | publication.Generation != m.request.Generation || publication.Target != m.request.Preview.Target || | |||
| 88 | + | publication.Width != m.request.Preview.Width || publication.Height != m.request.Preview.Height { | |||
| 89 | + | return false | |||
| 90 | + | } | |||
| 91 | + | picker.SetPreview(publication.Preview) | |||
| 92 | + | return true | |||
| 93 | + | } | |||
A internal/usecase/client/preview_manager_test.go +178 -0
| @@ -0,0 +1,178 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev/internal/domain" | |
| 10 | + | "github.com/bnema/vev/internal/ports" | |
| 11 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 12 | + | "github.com/bnema/vev/internal/protocol" | |
| 13 | + | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func previewTestSession(name string, seed byte, tab string) catalogue.RemoteCatalogSession { | |
| 17 | + | session := pickerTestSession(name, seed, catalogue_Up) | |
| 18 | + | session.Tabs = []catalogue.RemoteCatalogTab{{ID: tab, Name: tab}} | |
| 19 | + | session.ActiveTabID = tab | |
| 20 | + | return session | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func previewTestController(t *testing.T, observations ...ports.BrokerDaemonObservation) *pickerController { | |
| 24 | + | t.Helper() | |
| 25 | + | controller, clock := pickerTestController(t) | |
| 26 | + | controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 7, Revision: 1, Daemons: observations}) | |
| 27 | + | require.NotEmpty(t, controller.Render(domain.Size{Cols: 100, Rows: 40})) | |
| 28 | + | _ = clock | |
| 29 | + | return controller | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func expectPreviewSubscription(t *testing.T, service *portsmocks.MockBrokerService, connection ports.BrokerConnectionID, stream ports.BrokerStreamID, expected ports.BrokerPreviewRequest) *portsmocks.MockBrokerPreviewSubscription { | |
| 33 | + | t.Helper() | |
| 34 | + | sub := portsmocks.NewMockBrokerPreviewSubscription(t) | |
| 35 | + | service.EXPECT().ConnectionID().Return(connection).Times(3) | |
| 36 | + | service.EXPECT().NextStreamID().Return(stream, nil).Once() | |
| 37 | + | service.EXPECT().SubscribePreview(expected).Return(sub, nil).Once() | |
| 38 | + | return sub | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func expectedPreviewRequest(t *testing.T, controller *pickerController, connection ports.BrokerConnectionID, stream ports.BrokerStreamID, generation ports.BrokerPreviewGeneration, size domain.Size) ports.BrokerPreviewRequest { | |
| 42 | + | t.Helper() | |
| 43 | + | route, preview, ok := controller.PreviewRequest(connection, stream, size) | |
| 44 | + | if !ok { | |
| 45 | + | key, selected := controller.CursorKey() | |
| 46 | + | t.Fatalf("preview request refused: selected=%v key=%q connection=%v stream=%v size=%+v", selected, key, connection, stream, size) | |
| 47 | + | } | |
| 48 | + | return ports.BrokerPreviewRequest{Epoch: route.Epoch, Connection: connection, Generation: generation, Route: route, Preview: preview} | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func TestPreviewManagerInitialSelectionUsesExactLocalOrRemoteRequestAndDimensions(t *testing.T) { | |
| 52 | + | now := time.Unix(100, 0) | |
| 53 | + | cases := []struct { | |
| 54 | + | name string | |
| 55 | + | observations []ports.BrokerDaemonObservation | |
| 56 | + | wantEndpoint string | |
| 57 | + | }{ | |
| 58 | + | {name: "local", observations: []ports.BrokerDaemonObservation{pickerTestLocalObservation(now, previewTestSession("local-session", 1, "tab-local"))}, wantEndpoint: "local"}, | |
| 59 | + | {name: "remote", observations: []ports.BrokerDaemonObservation{pickerTestRemoteObservation("ssh://example.test", 2, 3, now, previewTestSession("remote-session", 4, "tab-remote"))}, wantEndpoint: "ssh://example.test"}, | |
| 60 | + | } | |
| 61 | + | for _, tc := range cases { | |
| 62 | + | t.Run(tc.name, func(t *testing.T) { | |
| 63 | + | controller := previewTestController(t, tc.observations...) | |
| 64 | + | service := portsmocks.NewMockBrokerService(t) | |
| 65 | + | connection := ports.BrokerConnectionID{11} | |
| 66 | + | size := domain.Size{Cols: 100, Rows: 40} | |
| 67 | + | expected := expectedPreviewRequest(t, controller, connection, 21, 2, size) | |
| 68 | + | require.Equal(t, tc.wantEndpoint, expected.Preview.Target.Endpoint) | |
| 69 | + | require.Equal(t, pickerPreviewSize(size), domain.Size{Cols: int(expected.Preview.Width), Rows: int(expected.Preview.Height)}) | |
| 70 | + | expectPreviewSubscription(t, service, connection, 21, expected) | |
| 71 | + | ||
| 72 | + | var manager previewManager | |
| 73 | + | manager.refresh(service, controller, size) | |
| 74 | + | require.Equal(t, expected, manager.request) | |
| 75 | + | }) | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func TestPreviewManagerSelectionAndResizeReplaceAndCloseOld(t *testing.T) { | |
| 80 | + | now := time.Unix(100, 0) | |
| 81 | + | controller := previewTestController(t, pickerTestLocalObservation(now, | |
| 82 | + | previewTestSession("alpha", 1, "tab-alpha"), | |
| 83 | + | previewTestSession("beta", 2, "tab-beta"), | |
| 84 | + | )) | |
| 85 | + | service := portsmocks.NewMockBrokerService(t) | |
| 86 | + | connection := ports.BrokerConnectionID{11} | |
| 87 | + | size := domain.Size{Cols: 100, Rows: 40} | |
| 88 | + | ||
| 89 | + | firstExpected := expectedPreviewRequest(t, controller, connection, 21, 2, size) | |
| 90 | + | first := expectPreviewSubscription(t, service, connection, 21, firstExpected) | |
| 91 | + | first.EXPECT().Close().Once() | |
| 92 | + | var manager previewManager | |
| 93 | + | manager.refresh(service, controller, size) | |
| 94 | + | ||
| 95 | + | require.True(t, controller.ConsumeTerminalRead([]byte("j"))) | |
| 96 | + | secondExpected := expectedPreviewRequest(t, controller, connection, 22, 4, size) | |
| 97 | + | second := expectPreviewSubscription(t, service, connection, 22, secondExpected) | |
| 98 | + | second.EXPECT().Close().Once() | |
| 99 | + | manager.refresh(service, controller, size) | |
| 100 | + | require.NotEqual(t, firstExpected.Preview.Target, secondExpected.Preview.Target) | |
| 101 | + | ||
| 102 | + | resized := domain.Size{Cols: 120, Rows: 50} | |
| 103 | + | thirdExpected := expectedPreviewRequest(t, controller, connection, 23, 6, resized) | |
| 104 | + | third := expectPreviewSubscription(t, service, connection, 23, thirdExpected) | |
| 105 | + | manager.refresh(service, controller, resized) | |
| 106 | + | require.NotEqual(t, secondExpected.Preview.Width, thirdExpected.Preview.Width) | |
| 107 | + | require.NotEqual(t, secondExpected.Preview.Height, thirdExpected.Preview.Height) | |
| 108 | + | ||
| 109 | + | third.EXPECT().Close().Once() | |
| 110 | + | manager.close(controller) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func TestPreviewManagerIgnoresOldLatePublicationAfterReplacement(t *testing.T) { | |
| 114 | + | now := time.Unix(100, 0) | |
| 115 | + | controller := previewTestController(t, pickerTestLocalObservation(now, | |
| 116 | + | previewTestSession("alpha", 1, "tab-alpha"), | |
| 117 | + | previewTestSession("beta", 2, "tab-beta"), | |
| 118 | + | )) | |
| 119 | + | service := portsmocks.NewMockBrokerService(t) | |
| 120 | + | connection := ports.BrokerConnectionID{11} | |
| 121 | + | size := domain.Size{Cols: 100, Rows: 40} | |
| 122 | + | firstExpected := expectedPreviewRequest(t, controller, connection, 21, 2, size) | |
| 123 | + | first := expectPreviewSubscription(t, service, connection, 21, firstExpected) | |
| 124 | + | first.EXPECT().Close().Once() | |
| 125 | + | var manager previewManager | |
| 126 | + | manager.refresh(service, controller, size) | |
| 127 | + | ||
| 128 | + | require.True(t, controller.ConsumeTerminalRead([]byte("j"))) | |
| 129 | + | secondExpected := expectedPreviewRequest(t, controller, connection, 22, 4, size) | |
| 130 | + | second := expectPreviewSubscription(t, service, connection, 22, secondExpected) | |
| 131 | + | manager.refresh(service, controller, size) | |
| 132 | + | ||
| 133 | + | // A wake already selected from the retired subscription is fenced by all | |
| 134 | + | // repeated authority fields and must not replace the current preview. | |
| 135 | + | late := ports.BrokerPreviewPublication{Epoch: firstExpected.Epoch, Connection: connection, Generation: firstExpected.Generation, Target: firstExpected.Preview.Target, Width: firstExpected.Preview.Width, Height: firstExpected.Preview.Height, Preview: protocol.RemotePreview{Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewUnavailable}} | |
| 136 | + | second.EXPECT().Latest().Return(late).Once() | |
| 137 | + | require.False(t, manager.publish(controller)) | |
| 138 | + | ||
| 139 | + | second.EXPECT().Close().Once() | |
| 140 | + | manager.close(controller) | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | func TestSupervisorRefreshPreviewWiresTerminalGeometryToPreviewManager(t *testing.T) { | |
| 144 | + | now := time.Unix(100, 0) | |
| 145 | + | controller := previewTestController(t, pickerTestLocalObservation(now, previewTestSession("alpha", 1, "tab-alpha"))) | |
| 146 | + | service := portsmocks.NewMockBrokerService(t) | |
| 147 | + | connection := ports.BrokerConnectionID{11} | |
| 148 | + | size := domain.Size{Cols: 80, Rows: 24} | |
| 149 | + | expected := expectedPreviewRequest(t, controller, connection, 21, 2, size) | |
| 150 | + | sub := expectPreviewSubscription(t, service, connection, 21, expected) | |
| 151 | + | ||
| 152 | + | supervisor := &Supervisor{cfg: SupervisorConfig{Picker: controller, Terminal: &pickerRecordingTerminal{}}} | |
| 153 | + | supervisor.refreshPreview(service) | |
| 154 | + | require.Equal(t, expected, supervisor.preview.request) | |
| 155 | + | ||
| 156 | + | sub.EXPECT().Close().Once() | |
| 157 | + | supervisor.preview.close(controller) | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | func TestPreviewManagerBrokerRetirementAndCommitClearAndClose(t *testing.T) { | |
| 161 | + | for _, reason := range []string{"broker retirement", "commit"} { | |
| 162 | + | t.Run(reason, func(t *testing.T) { | |
| 163 | + | now := time.Unix(100, 0) | |
| 164 | + | controller := previewTestController(t, pickerTestLocalObservation(now, previewTestSession("alpha", 1, "tab-alpha"))) | |
| 165 | + | service := portsmocks.NewMockBrokerService(t) | |
| 166 | + | connection := ports.BrokerConnectionID{11} | |
| 167 | + | size := domain.Size{Cols: 100, Rows: 40} | |
| 168 | + | expected := expectedPreviewRequest(t, controller, connection, 21, 2, size) | |
| 169 | + | sub := expectPreviewSubscription(t, service, connection, 21, expected) | |
| 170 | + | sub.EXPECT().Close().Once() | |
| 171 | + | var manager previewManager | |
| 172 | + | manager.refresh(service, controller, size) | |
| 173 | + | manager.close(controller) | |
| 174 | + | require.Nil(t, manager.sub) | |
| 175 | + | require.Equal(t, ports.BrokerPreviewRequest{}, manager.request) | |
| 176 | + | }) | |
| 177 | + | } | |
| 178 | + | } | |
| @@ -0,0 +1,178 @@ | |||||
| 1 | + | package client | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/vev/internal/domain" | |||
| 10 | + | "github.com/bnema/vev/internal/ports" | |||
| 11 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |||
| 12 | + | "github.com/bnema/vev/internal/protocol" | |||
| 13 | + | "github.com/bnema/vev/internal/protocol/catalogue" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | func previewTestSession(name string, seed byte, tab string) catalogue.RemoteCatalogSession { | |||
| 17 | + | session := pickerTestSession(name, seed, catalogue_Up) | |||
| 18 | + | session.Tabs = []catalogue.RemoteCatalogTab{{ID: tab, Name: tab}} | |||
| 19 | + | session.ActiveTabID = tab | |||
| 20 | + | return session | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func previewTestController(t *testing.T, observations ...ports.BrokerDaemonObservation) *pickerController { | |||
| 24 | + | t.Helper() | |||
| 25 | + | controller, clock := pickerTestController(t) | |||
| 26 | + | controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 7, Revision: 1, Daemons: observations}) | |||
| 27 | + | require.NotEmpty(t, controller.Render(domain.Size{Cols: 100, Rows: 40})) | |||
| 28 | + | _ = clock | |||
| 29 | + | return controller | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | func expectPreviewSubscription(t *testing.T, service *portsmocks.MockBrokerService, connection ports.BrokerConnectionID, stream ports.BrokerStreamID, expected ports.BrokerPreviewRequest) *portsmocks.MockBrokerPreviewSubscription { | |||
| 33 | + | t.Helper() | |||
| 34 | + | sub := portsmocks.NewMockBrokerPreviewSubscription(t) | |||
| 35 | + | service.EXPECT().ConnectionID().Return(connection).Times(3) | |||
| 36 | + | service.EXPECT().NextStreamID().Return(stream, nil).Once() | |||
| 37 | + | service.EXPECT().SubscribePreview(expected).Return(sub, nil).Once() | |||
| 38 | + | return sub | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func expectedPreviewRequest(t *testing.T, controller *pickerController, connection ports.BrokerConnectionID, stream ports.BrokerStreamID, generation ports.BrokerPreviewGeneration, size domain.Size) ports.BrokerPreviewRequest { | |||
| 42 | + | t.Helper() | |||
| 43 | + | route, preview, ok := controller.PreviewRequest(connection, stream, size) | |||
| 44 | + | if !ok { | |||
| 45 | + | key, selected := controller.CursorKey() | |||
| 46 | + | t.Fatalf("preview request refused: selected=%v key=%q connection=%v stream=%v size=%+v", selected, key, connection, stream, size) | |||
| 47 | + | } | |||
| 48 | + | return ports.BrokerPreviewRequest{Epoch: route.Epoch, Connection: connection, Generation: generation, Route: route, Preview: preview} | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func TestPreviewManagerInitialSelectionUsesExactLocalOrRemoteRequestAndDimensions(t *testing.T) { | |||
| 52 | + | now := time.Unix(100, 0) | |||
| 53 | + | cases := []struct { | |||
| 54 | + | name string | |||
| 55 | + | observations []ports.BrokerDaemonObservation | |||
| 56 | + | wantEndpoint string | |||
| 57 | + | }{ | |||
| 58 | + | {name: "local", observations: []ports.BrokerDaemonObservation{pickerTestLocalObservation(now, previewTestSession("local-session", 1, "tab-local"))}, wantEndpoint: "local"}, | |||
| 59 | + | {name: "remote", observations: []ports.BrokerDaemonObservation{pickerTestRemoteObservation("ssh://example.test", 2, 3, now, previewTestSession("remote-session", 4, "tab-remote"))}, wantEndpoint: "ssh://example.test"}, | |||
| 60 | + | } | |||
| 61 | + | for _, tc := range cases { | |||
| 62 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 63 | + | controller := previewTestController(t, tc.observations...) | |||
| 64 | + | service := portsmocks.NewMockBrokerService(t) | |||
| 65 | + | connection := ports.BrokerConnectionID{11} | |||
| 66 | + | size := domain.Size{Cols: 100, Rows: 40} | |||
| 67 | + | expected := expectedPreviewRequest(t, controller, connection, 21, 2, size) | |||
| 68 | + | require.Equal(t, tc.wantEndpoint, expected.Preview.Target.Endpoint) | |||
| 69 | + | require.Equal(t, pickerPreviewSize(size), domain.Size{Cols: int(expected.Preview.Width), Rows: int(expected.Preview.Height)}) | |||
| 70 | + | expectPreviewSubscription(t, service, connection, 21, expected) | |||
| 71 | + | ||||
| 72 | + | var manager previewManager | |||
| 73 | + | manager.refresh(service, controller, size) | |||
| 74 | + | require.Equal(t, expected, manager.request) | |||
| 75 | + | }) | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func TestPreviewManagerSelectionAndResizeReplaceAndCloseOld(t *testing.T) { | |||
| 80 | + | now := time.Unix(100, 0) | |||
| 81 | + | controller := previewTestController(t, pickerTestLocalObservation(now, | |||
| 82 | + | previewTestSession("alpha", 1, "tab-alpha"), | |||
| 83 | + | previewTestSession("beta", 2, "tab-beta"), | |||
| 84 | + | )) | |||
| 85 | + | service := portsmocks.NewMockBrokerService(t) | |||
| 86 | + | connection := ports.BrokerConnectionID{11} | |||
| 87 | + | size := domain.Size{Cols: 100, Rows: 40} | |||
| 88 | + | ||||
| 89 | + | firstExpected := expectedPreviewRequest(t, controller, connection, 21, 2, size) | |||
| 90 | + | first := expectPreviewSubscription(t, service, connection, 21, firstExpected) | |||
| 91 | + | first.EXPECT().Close().Once() | |||
| 92 | + | var manager previewManager | |||
| 93 | + | manager.refresh(service, controller, size) | |||
| 94 | + | ||||
| 95 | + | require.True(t, controller.ConsumeTerminalRead([]byte("j"))) | |||
| 96 | + | secondExpected := expectedPreviewRequest(t, controller, connection, 22, 4, size) | |||
| 97 | + | second := expectPreviewSubscription(t, service, connection, 22, secondExpected) | |||
| 98 | + | second.EXPECT().Close().Once() | |||
| 99 | + | manager.refresh(service, controller, size) | |||
| 100 | + | require.NotEqual(t, firstExpected.Preview.Target, secondExpected.Preview.Target) | |||
| 101 | + | ||||
| 102 | + | resized := domain.Size{Cols: 120, Rows: 50} | |||
| 103 | + | thirdExpected := expectedPreviewRequest(t, controller, connection, 23, 6, resized) | |||
| 104 | + | third := expectPreviewSubscription(t, service, connection, 23, thirdExpected) | |||
| 105 | + | manager.refresh(service, controller, resized) | |||
| 106 | + | require.NotEqual(t, secondExpected.Preview.Width, thirdExpected.Preview.Width) | |||
| 107 | + | require.NotEqual(t, secondExpected.Preview.Height, thirdExpected.Preview.Height) | |||
| 108 | + | ||||
| 109 | + | third.EXPECT().Close().Once() | |||
| 110 | + | manager.close(controller) | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func TestPreviewManagerIgnoresOldLatePublicationAfterReplacement(t *testing.T) { | |||
| 114 | + | now := time.Unix(100, 0) | |||
| 115 | + | controller := previewTestController(t, pickerTestLocalObservation(now, | |||
| 116 | + | previewTestSession("alpha", 1, "tab-alpha"), | |||
| 117 | + | previewTestSession("beta", 2, "tab-beta"), | |||
| 118 | + | )) | |||
| 119 | + | service := portsmocks.NewMockBrokerService(t) | |||
| 120 | + | connection := ports.BrokerConnectionID{11} | |||
| 121 | + | size := domain.Size{Cols: 100, Rows: 40} | |||
| 122 | + | firstExpected := expectedPreviewRequest(t, controller, connection, 21, 2, size) | |||
| 123 | + | first := expectPreviewSubscription(t, service, connection, 21, firstExpected) | |||
| 124 | + | first.EXPECT().Close().Once() | |||
| 125 | + | var manager previewManager | |||
| 126 | + | manager.refresh(service, controller, size) | |||
| 127 | + | ||||
| 128 | + | require.True(t, controller.ConsumeTerminalRead([]byte("j"))) | |||
| 129 | + | secondExpected := expectedPreviewRequest(t, controller, connection, 22, 4, size) | |||
| 130 | + | second := expectPreviewSubscription(t, service, connection, 22, secondExpected) | |||
| 131 | + | manager.refresh(service, controller, size) | |||
| 132 | + | ||||
| 133 | + | // A wake already selected from the retired subscription is fenced by all | |||
| 134 | + | // repeated authority fields and must not replace the current preview. | |||
| 135 | + | late := ports.BrokerPreviewPublication{Epoch: firstExpected.Epoch, Connection: connection, Generation: firstExpected.Generation, Target: firstExpected.Preview.Target, Width: firstExpected.Preview.Width, Height: firstExpected.Preview.Height, Preview: protocol.RemotePreview{Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewUnavailable}} | |||
| 136 | + | second.EXPECT().Latest().Return(late).Once() | |||
| 137 | + | require.False(t, manager.publish(controller)) | |||
| 138 | + | ||||
| 139 | + | second.EXPECT().Close().Once() | |||
| 140 | + | manager.close(controller) | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | func TestSupervisorRefreshPreviewWiresTerminalGeometryToPreviewManager(t *testing.T) { | |||
| 144 | + | now := time.Unix(100, 0) | |||
| 145 | + | controller := previewTestController(t, pickerTestLocalObservation(now, previewTestSession("alpha", 1, "tab-alpha"))) | |||
| 146 | + | service := portsmocks.NewMockBrokerService(t) | |||
| 147 | + | connection := ports.BrokerConnectionID{11} | |||
| 148 | + | size := domain.Size{Cols: 80, Rows: 24} | |||
| 149 | + | expected := expectedPreviewRequest(t, controller, connection, 21, 2, size) | |||
| 150 | + | sub := expectPreviewSubscription(t, service, connection, 21, expected) | |||
| 151 | + | ||||
| 152 | + | supervisor := &Supervisor{cfg: SupervisorConfig{Picker: controller, Terminal: &pickerRecordingTerminal{}}} | |||
| 153 | + | supervisor.refreshPreview(service) | |||
| 154 | + | require.Equal(t, expected, supervisor.preview.request) | |||
| 155 | + | ||||
| 156 | + | sub.EXPECT().Close().Once() | |||
| 157 | + | supervisor.preview.close(controller) | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | func TestPreviewManagerBrokerRetirementAndCommitClearAndClose(t *testing.T) { | |||
| 161 | + | for _, reason := range []string{"broker retirement", "commit"} { | |||
| 162 | + | t.Run(reason, func(t *testing.T) { | |||
| 163 | + | now := time.Unix(100, 0) | |||
| 164 | + | controller := previewTestController(t, pickerTestLocalObservation(now, previewTestSession("alpha", 1, "tab-alpha"))) | |||
| 165 | + | service := portsmocks.NewMockBrokerService(t) | |||
| 166 | + | connection := ports.BrokerConnectionID{11} | |||
| 167 | + | size := domain.Size{Cols: 100, Rows: 40} | |||
| 168 | + | expected := expectedPreviewRequest(t, controller, connection, 21, 2, size) | |||
| 169 | + | sub := expectPreviewSubscription(t, service, connection, 21, expected) | |||
| 170 | + | sub.EXPECT().Close().Once() | |||
| 171 | + | var manager previewManager | |||
| 172 | + | manager.refresh(service, controller, size) | |||
| 173 | + | manager.close(controller) | |||
| 174 | + | require.Nil(t, manager.sub) | |||
| 175 | + | require.Equal(t, ports.BrokerPreviewRequest{}, manager.request) | |||
| 176 | + | }) | |||
| 177 | + | } | |||
| 178 | + | } | |||
A internal/usecase/client/session_picker_cutover_test.go +54 -0
| @@ -0,0 +1,54 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "sync" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/vev/internal/ports" | |
| 11 | + | "github.com/bnema/vev/internal/protocol" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // The daemon's SSP action ends the attachment, not an in-attachment modal. | |
| 15 | + | // Exercise the real worker and supervisor so a notice-only or paint-only fix | |
| 16 | + | // cannot pass while the attachment still owns terminal input. | |
| 17 | + | func TestSessionPickerDetachRevokesForegroundBeforePicker(t *testing.T) { | |
| 18 | + | picker := newAttachTestPicker() | |
| 19 | + | notices := make(chan LifecycleNotice, 4) | |
| 20 | + | harness := startAttachHarnessConfig(t, picker, func(cfg *SupervisorConfig) { | |
| 21 | + | cfg.NotifyLifecycle = func(notice LifecycleNotice) { notices <- notice } | |
| 22 | + | }) | |
| 23 | + | stream := newSessionTestStream() | |
| 24 | + | harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |
| 25 | + | return stream, nil | |
| 26 | + | }) | |
| 27 | + | picker.commit(sessionTestRequest(true)) | |
| 28 | + | awaitStreamHello(t, &sync.Mutex{}, &stream) | |
| 29 | + | deliverReadyStream(t, stream) | |
| 30 | + | awaitAttachedState(t, harness.sup) | |
| 31 | + | foreground := harness.sup.attachments.authority.foreground() | |
| 32 | + | require.NotNil(t, foreground) | |
| 33 | + | stream.deliver(protocol.Detached{Reason: protocol.ReasonDetachToPicker}) | |
| 34 | + | awaitPickerState(t, harness.sup) | |
| 35 | + | require.Equal(t, LifecycleNoticeDetachToPicker, (<-notices).Kind) | |
| 36 | + | require.Nil(t, harness.sup.attachments.authority.foreground()) | |
| 37 | + | select { | |
| 38 | + | case <-foreground.Done(): | |
| 39 | + | default: | |
| 40 | + | t.Fatal("picker published before attachment foreground drained") | |
| 41 | + | } | |
| 42 | + | require.True(t, picker.owns()) | |
| 43 | + | require.Len(t, harness.service.openedRequests(), 1, "picker transfer must not reattach") | |
| 44 | + | // Reopening is possible only through a fresh client-owned selection. | |
| 45 | + | next := newSessionTestStream() | |
| 46 | + | harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |
| 47 | + | return next, nil | |
| 48 | + | }) | |
| 49 | + | picker.commit(sessionTestRequest(true)) | |
| 50 | + | awaitStreamHello(t, &sync.Mutex{}, &next) | |
| 51 | + | deliverReadyStream(t, next) | |
| 52 | + | awaitAttachedState(t, harness.sup) | |
| 53 | + | require.NotEqual(t, foreground.Token(), harness.sup.attachments.authority.currentToken()) | |
| 54 | + | } | |
| @@ -0,0 +1,54 @@ | |||||
| 1 | + | package client | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "sync" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/vev/internal/ports" | |||
| 11 | + | "github.com/bnema/vev/internal/protocol" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // The daemon's SSP action ends the attachment, not an in-attachment modal. | |||
| 15 | + | // Exercise the real worker and supervisor so a notice-only or paint-only fix | |||
| 16 | + | // cannot pass while the attachment still owns terminal input. | |||
| 17 | + | func TestSessionPickerDetachRevokesForegroundBeforePicker(t *testing.T) { | |||
| 18 | + | picker := newAttachTestPicker() | |||
| 19 | + | notices := make(chan LifecycleNotice, 4) | |||
| 20 | + | harness := startAttachHarnessConfig(t, picker, func(cfg *SupervisorConfig) { | |||
| 21 | + | cfg.NotifyLifecycle = func(notice LifecycleNotice) { notices <- notice } | |||
| 22 | + | }) | |||
| 23 | + | stream := newSessionTestStream() | |||
| 24 | + | harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |||
| 25 | + | return stream, nil | |||
| 26 | + | }) | |||
| 27 | + | picker.commit(sessionTestRequest(true)) | |||
| 28 | + | awaitStreamHello(t, &sync.Mutex{}, &stream) | |||
| 29 | + | deliverReadyStream(t, stream) | |||
| 30 | + | awaitAttachedState(t, harness.sup) | |||
| 31 | + | foreground := harness.sup.attachments.authority.foreground() | |||
| 32 | + | require.NotNil(t, foreground) | |||
| 33 | + | stream.deliver(protocol.Detached{Reason: protocol.ReasonDetachToPicker}) | |||
| 34 | + | awaitPickerState(t, harness.sup) | |||
| 35 | + | require.Equal(t, LifecycleNoticeDetachToPicker, (<-notices).Kind) | |||
| 36 | + | require.Nil(t, harness.sup.attachments.authority.foreground()) | |||
| 37 | + | select { | |||
| 38 | + | case <-foreground.Done(): | |||
| 39 | + | default: | |||
| 40 | + | t.Fatal("picker published before attachment foreground drained") | |||
| 41 | + | } | |||
| 42 | + | require.True(t, picker.owns()) | |||
| 43 | + | require.Len(t, harness.service.openedRequests(), 1, "picker transfer must not reattach") | |||
| 44 | + | // Reopening is possible only through a fresh client-owned selection. | |||
| 45 | + | next := newSessionTestStream() | |||
| 46 | + | harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |||
| 47 | + | return next, nil | |||
| 48 | + | }) | |||
| 49 | + | picker.commit(sessionTestRequest(true)) | |||
| 50 | + | awaitStreamHello(t, &sync.Mutex{}, &next) | |||
| 51 | + | deliverReadyStream(t, next) | |||
| 52 | + | awaitAttachedState(t, harness.sup) | |||
| 53 | + | require.NotEqual(t, foreground.Token(), harness.sup.attachments.authority.currentToken()) | |||
| 54 | + | } | |||
M internal/usecase/client/supervisor.go +66 -13
| @@ -349,6 +349,11 @@ type AttachmentLifecycleAction struct { | |||
| 349 | 349 | type Supervisor struct { | |
| 350 | 350 | cfg SupervisorConfig | |
| 351 | 351 | ||
| 352 | + | // pendingPickerKey retains a commit observed while connecting. It is | |
| 353 | + | // revalidated against the adopted service just like a ready-phase commit. | |
| 354 | + | pendingPickerKey string | |
| 355 | + | preview previewManager | |
| 356 | + | ||
| 352 | 357 | mu sync.Mutex | |
| 353 | 358 | state State | |
| 354 | 359 | ||
| @@ -468,6 +473,7 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) { | |||
| 468 | 473 | sub ports.BrokerSubscription | |
| 469 | 474 | ) | |
| 470 | 475 | retire := func() { | |
| 476 | + | s.preview.close(s.cfg.Picker) | |
| 471 | 477 | if sub != nil { | |
| 472 | 478 | sub.Close() | |
| 473 | 479 | sub = nil | |
| @@ -545,6 +551,7 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) { | |||
| 545 | 551 | } | |
| 546 | 552 | wasReadyLost := s.State().ReadyLost | |
| 547 | 553 | s.transition(supervisorEvent{kind: supervisorReady}) | |
| 554 | + | s.refreshPreview(service) | |
| 548 | 555 | if wasReadyLost { | |
| 549 | 556 | s.notifyLifecycle(LifecycleNoticeBrokerReconnected) | |
| 550 | 557 | s.mu.Lock() | |
| @@ -720,6 +727,10 @@ func (s *Supervisor) awaitAttempt(ctx context.Context, input *terminalInputLifet | |||
| 720 | 727 | select { | |
| 721 | 728 | case result := <-outcome: | |
| 722 | 729 | return result, false, nil | |
| 730 | + | case <-s.pickerOps(): | |
| 731 | + | if s.takePickerClose() { | |
| 732 | + | return supervisorAttempt{}, true, nil | |
| 733 | + | } | |
| 723 | 734 | case <-ctx.Done(): | |
| 724 | 735 | return supervisorAttempt{}, true, ctx.Err() | |
| 725 | 736 | case err := <-input.EOF(): | |
| @@ -753,6 +764,10 @@ type readyOutcome struct { | |||
| 753 | 764 | // the supervisor can admit exactly the row the user committed. It opens no | |
| 754 | 765 | // stream and starts no terminal work itself. | |
| 755 | 766 | func (s *Supervisor) awaitReady(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, sub ports.BrokerSubscription) readyOutcome { | |
| 767 | + | if key := s.pendingPickerKey; key != "" { | |
| 768 | + | s.pendingPickerKey = "" | |
| 769 | + | return readyOutcome{commitKey: key} | |
| 770 | + | } | |
| 756 | 771 | var changed <-chan struct{} | |
| 757 | 772 | var ops <-chan struct{} | |
| 758 | 773 | if s.cfg.Picker != nil { | |
| @@ -767,9 +782,25 @@ func (s *Supervisor) awaitReady(ctx context.Context, input *terminalInputLifetim | |||
| 767 | 782 | return readyOutcome{lossErr: service.Err()} | |
| 768 | 783 | case <-changed: | |
| 769 | 784 | s.cfg.Picker.ApplySnapshot(service.Snapshot()) | |
| 785 | + | s.refreshPreview(service) | |
| 770 | 786 | s.renderCurrent() | |
| 787 | + | case <-s.preview.changed(): | |
| 788 | + | if s.preview.publish(s.cfg.Picker) { | |
| 789 | + | s.renderCurrent() | |
| 790 | + | } | |
| 771 | 791 | case <-ops: | |
| 772 | - | if key, ok := s.takeCommittedKey(); ok { | |
| 792 | + | op, key := s.cfg.Picker.TakeOp() | |
| 793 | + | if !op.close && !op.commit { | |
| 794 | + | s.refreshPreview(service) | |
| 795 | + | s.renderCurrent() | |
| 796 | + | continue | |
| 797 | + | } | |
| 798 | + | if op.close { | |
| 799 | + | s.preview.close(s.cfg.Picker) | |
| 800 | + | return readyOutcome{terminated: true} | |
| 801 | + | } | |
| 802 | + | if op.commit && key != "" { | |
| 803 | + | s.preview.close(s.cfg.Picker) | |
| 773 | 804 | return readyOutcome{commitKey: key} | |
| 774 | 805 | } | |
| 775 | 806 | case <-ctx.Done(): | |
| @@ -777,25 +808,39 @@ func (s *Supervisor) awaitReady(ctx context.Context, input *terminalInputLifetim | |||
| 777 | 808 | case err := <-input.EOF(): | |
| 778 | 809 | return readyOutcome{terminated: true, termErr: terminalReadCause(err)} | |
| 779 | 810 | case <-s.presentationInvalidation(): | |
| 811 | + | s.refreshPreview(service) | |
| 780 | 812 | s.renderResizeInvalidation() | |
| 781 | 813 | } | |
| 782 | 814 | } | |
| 783 | 815 | } | |
| 784 | 816 | ||
| 785 | - | // takeCommittedKey drains exactly one committed presentation decision. Only a | |
| 786 | - | // commit with a captured catalogue key admits an attachment; every other | |
| 787 | - | // decision (cursor moves, search, kill, close) is consumed and dropped here, | |
| 788 | - | // so picker input alone can never open, write, or retarget a session. | |
| 789 | - | func (s *Supervisor) takeCommittedKey() (string, bool) { | |
| 790 | - | picker := s.cfg.Picker | |
| 791 | - | if picker == nil { | |
| 792 | - | return "", false | |
| 817 | + | func (s *Supervisor) refreshPreview(service ports.BrokerService) { | |
| 818 | + | if s == nil || s.cfg.Picker == nil { | |
| 819 | + | return | |
| 793 | 820 | } | |
| 794 | - | op, key := picker.TakeOp() | |
| 795 | - | if !op.commit || key == "" { | |
| 796 | - | return "", false | |
| 821 | + | geometry, err := s.cfg.Terminal.Geometry() | |
| 822 | + | if err != nil || !geometry.Valid() { | |
| 823 | + | s.preview.close(s.cfg.Picker) | |
| 824 | + | return | |
| 797 | 825 | } | |
| 798 | - | return key, true | |
| 826 | + | s.preview.refresh(service, s.cfg.Picker, geometry.Size) | |
| 827 | + | } | |
| 828 | + | ||
| 829 | + | // pickerOps is nil when no picker owns presentation decisions. | |
| 830 | + | func (s *Supervisor) pickerOps() <-chan struct{} { | |
| 831 | + | if s.cfg.Picker == nil { | |
| 832 | + | return nil | |
| 833 | + | } | |
| 834 | + | return s.cfg.Picker.OpsReady() | |
| 835 | + | } | |
| 836 | + | ||
| 837 | + | // takePickerClose honors exit while offline without losing a racing commit. | |
| 838 | + | func (s *Supervisor) takePickerClose() bool { | |
| 839 | + | op, key := s.cfg.Picker.TakeOp() | |
| 840 | + | if op.commit && key != "" { | |
| 841 | + | s.pendingPickerKey = key | |
| 842 | + | } | |
| 843 | + | return op.close | |
| 799 | 844 | } | |
| 800 | 845 | ||
| 801 | 846 | // awaitTermination parks the supervisor on the picker after a non-retryable | |
| @@ -804,6 +849,10 @@ func (s *Supervisor) takeCommittedKey() (string, bool) { | |||
| 804 | 849 | func (s *Supervisor) awaitTermination(ctx context.Context, input *terminalInputLifetime) error { | |
| 805 | 850 | for { | |
| 806 | 851 | select { | |
| 852 | + | case <-s.pickerOps(): | |
| 853 | + | if s.takePickerClose() { | |
| 854 | + | return nil | |
| 855 | + | } | |
| 807 | 856 | case <-ctx.Done(): | |
| 808 | 857 | return ctx.Err() | |
| 809 | 858 | case err := <-input.EOF(): | |
| @@ -826,6 +875,10 @@ func (s *Supervisor) waitBackoff(ctx context.Context, input *terminalInputLifeti | |||
| 826 | 875 | defer stopSupervisorTimer(timer) | |
| 827 | 876 | for { | |
| 828 | 877 | select { | |
| 878 | + | case <-s.pickerOps(): | |
| 879 | + | if s.takePickerClose() { | |
| 880 | + | return true, nil | |
| 881 | + | } | |
| 829 | 882 | case <-timer.C(): | |
| 830 | 883 | return false, nil | |
| 831 | 884 | case <-ctx.Done(): | |
| @@ -349,6 +349,11 @@ type AttachmentLifecycleAction struct { | |||||
| 349 | type Supervisor struct { | 349 | type Supervisor struct { | ||
| 350 | cfg SupervisorConfig | 350 | cfg SupervisorConfig | ||
| 351 | 351 | ||||
| 352 | + | // pendingPickerKey retains a commit observed while connecting. It is | |||
| 353 | + | // revalidated against the adopted service just like a ready-phase commit. | |||
| 354 | + | pendingPickerKey string | |||
| 355 | + | preview previewManager | |||
| 356 | + | ||||
| 352 | mu sync.Mutex | 357 | mu sync.Mutex | ||
| 353 | state State | 358 | state State | ||
| 354 | 359 | ||||
| @@ -468,6 +473,7 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) { | |||||
| 468 | sub ports.BrokerSubscription | 473 | sub ports.BrokerSubscription | ||
| 469 | ) | 474 | ) | ||
| 470 | retire := func() { | 475 | retire := func() { | ||
| 476 | + | s.preview.close(s.cfg.Picker) | |||
| 471 | if sub != nil { | 477 | if sub != nil { | ||
| 472 | sub.Close() | 478 | sub.Close() | ||
| 473 | sub = nil | 479 | sub = nil | ||
| @@ -545,6 +551,7 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) { | |||||
| 545 | } | 551 | } | ||
| 546 | wasReadyLost := s.State().ReadyLost | 552 | wasReadyLost := s.State().ReadyLost | ||
| 547 | s.transition(supervisorEvent{kind: supervisorReady}) | 553 | s.transition(supervisorEvent{kind: supervisorReady}) | ||
| 554 | + | s.refreshPreview(service) | |||
| 548 | if wasReadyLost { | 555 | if wasReadyLost { | ||
| 549 | s.notifyLifecycle(LifecycleNoticeBrokerReconnected) | 556 | s.notifyLifecycle(LifecycleNoticeBrokerReconnected) | ||
| 550 | s.mu.Lock() | 557 | s.mu.Lock() | ||
| @@ -720,6 +727,10 @@ func (s *Supervisor) awaitAttempt(ctx context.Context, input *terminalInputLifet | |||||
| 720 | select { | 727 | select { | ||
| 721 | case result := <-outcome: | 728 | case result := <-outcome: | ||
| 722 | return result, false, nil | 729 | return result, false, nil | ||
| 730 | + | case <-s.pickerOps(): | |||
| 731 | + | if s.takePickerClose() { | |||
| 732 | + | return supervisorAttempt{}, true, nil | |||
| 733 | + | } | |||
| 723 | case <-ctx.Done(): | 734 | case <-ctx.Done(): | ||
| 724 | return supervisorAttempt{}, true, ctx.Err() | 735 | return supervisorAttempt{}, true, ctx.Err() | ||
| 725 | case err := <-input.EOF(): | 736 | case err := <-input.EOF(): | ||
| @@ -753,6 +764,10 @@ type readyOutcome struct { | |||||
| 753 | // the supervisor can admit exactly the row the user committed. It opens no | 764 | // the supervisor can admit exactly the row the user committed. It opens no | ||
| 754 | // stream and starts no terminal work itself. | 765 | // stream and starts no terminal work itself. | ||
| 755 | func (s *Supervisor) awaitReady(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, sub ports.BrokerSubscription) readyOutcome { | 766 | func (s *Supervisor) awaitReady(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, sub ports.BrokerSubscription) readyOutcome { | ||
| 767 | + | if key := s.pendingPickerKey; key != "" { | |||
| 768 | + | s.pendingPickerKey = "" | |||
| 769 | + | return readyOutcome{commitKey: key} | |||
| 770 | + | } | |||
| 756 | var changed <-chan struct{} | 771 | var changed <-chan struct{} | ||
| 757 | var ops <-chan struct{} | 772 | var ops <-chan struct{} | ||
| 758 | if s.cfg.Picker != nil { | 773 | if s.cfg.Picker != nil { | ||
| @@ -767,9 +782,25 @@ func (s *Supervisor) awaitReady(ctx context.Context, input *terminalInputLifetim | |||||
| 767 | return readyOutcome{lossErr: service.Err()} | 782 | return readyOutcome{lossErr: service.Err()} | ||
| 768 | case <-changed: | 783 | case <-changed: | ||
| 769 | s.cfg.Picker.ApplySnapshot(service.Snapshot()) | 784 | s.cfg.Picker.ApplySnapshot(service.Snapshot()) | ||
| 785 | + | s.refreshPreview(service) | |||
| 770 | s.renderCurrent() | 786 | s.renderCurrent() | ||
| 787 | + | case <-s.preview.changed(): | |||
| 788 | + | if s.preview.publish(s.cfg.Picker) { | |||
| 789 | + | s.renderCurrent() | |||
| 790 | + | } | |||
| 771 | case <-ops: | 791 | case <-ops: | ||
| 772 | - | if key, ok := s.takeCommittedKey(); ok { | 792 | + | op, key := s.cfg.Picker.TakeOp() |
| 793 | + | if !op.close && !op.commit { | |||
| 794 | + | s.refreshPreview(service) | |||
| 795 | + | s.renderCurrent() | |||
| 796 | + | continue | |||
| 797 | + | } | |||
| 798 | + | if op.close { | |||
| 799 | + | s.preview.close(s.cfg.Picker) | |||
| 800 | + | return readyOutcome{terminated: true} | |||
| 801 | + | } | |||
| 802 | + | if op.commit && key != "" { | |||
| 803 | + | s.preview.close(s.cfg.Picker) | |||
| 773 | return readyOutcome{commitKey: key} | 804 | return readyOutcome{commitKey: key} | ||
| 774 | } | 805 | } | ||
| 775 | case <-ctx.Done(): | 806 | case <-ctx.Done(): | ||
| @@ -777,25 +808,39 @@ func (s *Supervisor) awaitReady(ctx context.Context, input *terminalInputLifetim | |||||
| 777 | case err := <-input.EOF(): | 808 | case err := <-input.EOF(): | ||
| 778 | return readyOutcome{terminated: true, termErr: terminalReadCause(err)} | 809 | return readyOutcome{terminated: true, termErr: terminalReadCause(err)} | ||
| 779 | case <-s.presentationInvalidation(): | 810 | case <-s.presentationInvalidation(): | ||
| 811 | + | s.refreshPreview(service) | |||
| 780 | s.renderResizeInvalidation() | 812 | s.renderResizeInvalidation() | ||
| 781 | } | 813 | } | ||
| 782 | } | 814 | } | ||
| 783 | } | 815 | } | ||
| 784 | 816 | ||||
| 785 | - | // takeCommittedKey drains exactly one committed presentation decision. Only a | 817 | + | func (s *Supervisor) refreshPreview(service ports.BrokerService) { |
| 786 | - | // commit with a captured catalogue key admits an attachment; every other | 818 | + | if s == nil || s.cfg.Picker == nil { |
| 787 | - | // decision (cursor moves, search, kill, close) is consumed and dropped here, | 819 | + | return |
| 788 | - | // so picker input alone can never open, write, or retarget a session. | |||
| 789 | - | func (s *Supervisor) takeCommittedKey() (string, bool) { | |||
| 790 | - | picker := s.cfg.Picker | |||
| 791 | - | if picker == nil { | |||
| 792 | - | return "", false | |||
| 793 | } | 820 | } | ||
| 794 | - | op, key := picker.TakeOp() | 821 | + | geometry, err := s.cfg.Terminal.Geometry() |
| 795 | - | if !op.commit || key == "" { | 822 | + | if err != nil || !geometry.Valid() { |
| 796 | - | return "", false | 823 | + | s.preview.close(s.cfg.Picker) |
| 824 | + | return | |||
| 797 | } | 825 | } | ||
| 798 | - | return key, true | 826 | + | s.preview.refresh(service, s.cfg.Picker, geometry.Size) |
| 827 | + | } | |||
| 828 | + | ||||
| 829 | + | // pickerOps is nil when no picker owns presentation decisions. | |||
| 830 | + | func (s *Supervisor) pickerOps() <-chan struct{} { | |||
| 831 | + | if s.cfg.Picker == nil { | |||
| 832 | + | return nil | |||
| 833 | + | } | |||
| 834 | + | return s.cfg.Picker.OpsReady() | |||
| 835 | + | } | |||
| 836 | + | ||||
| 837 | + | // takePickerClose honors exit while offline without losing a racing commit. | |||
| 838 | + | func (s *Supervisor) takePickerClose() bool { | |||
| 839 | + | op, key := s.cfg.Picker.TakeOp() | |||
| 840 | + | if op.commit && key != "" { | |||
| 841 | + | s.pendingPickerKey = key | |||
| 842 | + | } | |||
| 843 | + | return op.close | |||
| 799 | } | 844 | } | ||
| 800 | 845 | ||||
| 801 | // awaitTermination parks the supervisor on the picker after a non-retryable | 846 | // awaitTermination parks the supervisor on the picker after a non-retryable | ||
| @@ -804,6 +849,10 @@ func (s *Supervisor) takeCommittedKey() (string, bool) { | |||||
| 804 | func (s *Supervisor) awaitTermination(ctx context.Context, input *terminalInputLifetime) error { | 849 | func (s *Supervisor) awaitTermination(ctx context.Context, input *terminalInputLifetime) error { | ||
| 805 | for { | 850 | for { | ||
| 806 | select { | 851 | select { | ||
| 852 | + | case <-s.pickerOps(): | |||
| 853 | + | if s.takePickerClose() { | |||
| 854 | + | return nil | |||
| 855 | + | } | |||
| 807 | case <-ctx.Done(): | 856 | case <-ctx.Done(): | ||
| 808 | return ctx.Err() | 857 | return ctx.Err() | ||
| 809 | case err := <-input.EOF(): | 858 | case err := <-input.EOF(): | ||
| @@ -826,6 +875,10 @@ func (s *Supervisor) waitBackoff(ctx context.Context, input *terminalInputLifeti | |||||
| 826 | defer stopSupervisorTimer(timer) | 875 | defer stopSupervisorTimer(timer) | ||
| 827 | for { | 876 | for { | ||
| 828 | select { | 877 | select { | ||
| 878 | + | case <-s.pickerOps(): | |||
| 879 | + | if s.takePickerClose() { | |||
| 880 | + | return true, nil | |||
| 881 | + | } | |||
| 829 | case <-timer.C(): | 882 | case <-timer.C(): | ||
| 830 | return false, nil | 883 | return false, nil | ||
| 831 | case <-ctx.Done(): | 884 | case <-ctx.Done(): | ||
M internal/usecase/client/supervisor_integration_test.go +4 -0
| @@ -61,6 +61,10 @@ func (c *integrationCore) Subscribe() (ports.BrokerSubscription, error) { | |||
| 61 | 61 | return integrationSubscription{changed: c.changed}, nil | |
| 62 | 62 | } | |
| 63 | 63 | ||
| 64 | + | func (c *integrationCore) SubscribePreview(ports.BrokerPreviewRequest) (ports.BrokerPreviewSubscription, error) { | |
| 65 | + | return nil, errors.New("integration core does not support preview") | |
| 66 | + | } | |
| 67 | + | ||
| 64 | 68 | func (c *integrationCore) OpenStream(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |
| 65 | 69 | return nil, errors.New("integration core does not support streams") | |
| 66 | 70 | } | |
| @@ -61,6 +61,10 @@ func (c *integrationCore) Subscribe() (ports.BrokerSubscription, error) { | |||||
| 61 | return integrationSubscription{changed: c.changed}, nil | 61 | return integrationSubscription{changed: c.changed}, nil | ||
| 62 | } | 62 | } | ||
| 63 | 63 | ||||
| 64 | + | func (c *integrationCore) SubscribePreview(ports.BrokerPreviewRequest) (ports.BrokerPreviewSubscription, error) { | |||
| 65 | + | return nil, errors.New("integration core does not support preview") | |||
| 66 | + | } | |||
| 67 | + | ||||
| 64 | func (c *integrationCore) OpenStream(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | 68 | func (c *integrationCore) OpenStream(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | ||
| 65 | return nil, errors.New("integration core does not support streams") | 69 | return nil, errors.New("integration core does not support streams") | ||
| 66 | } | 70 | } | ||
M internal/usecase/client/supervisor_test.go +53 -0
| @@ -371,6 +371,10 @@ func (s *supervisorTestService) Subscribe() (ports.BrokerSubscription, error) { | |||
| 371 | 371 | }), nil | |
| 372 | 372 | } | |
| 373 | 373 | ||
| 374 | + | func (s *supervisorTestService) SubscribePreview(ports.BrokerPreviewRequest) (ports.BrokerPreviewSubscription, error) { | |
| 375 | + | return nil, errors.New("supervisor test service does not support preview") | |
| 376 | + | } | |
| 377 | + | ||
| 374 | 378 | func (s *supervisorTestService) OpenStream(ctx context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |
| 375 | 379 | s.mu.Lock() | |
| 376 | 380 | s.openCalls = append(s.openCalls, request) | |
| @@ -1437,3 +1441,52 @@ func TestSupervisorTestHubModelsSubscriptionContract(t *testing.T) { | |||
| 1437 | 1441 | } | |
| 1438 | 1442 | require.Equal(t, ports.BrokerRevision(4), hub.current().Revision) | |
| 1439 | 1443 | } | |
| 1444 | + | ||
| 1445 | + | // Raw-mode Ctrl-C is input, not SIGINT. It must work before a broker exists, | |
| 1446 | + | // including during a blocked connect and after permanent or transient failures. | |
| 1447 | + | func TestSupervisorPickerCloseWhileOffline(t *testing.T) { | |
| 1448 | + | for _, phase := range []string{"connecting", "retry", "nonretryable"} { | |
| 1449 | + | t.Run(phase, func(t *testing.T) { | |
| 1450 | + | reader := newPickerChunkReader() | |
| 1451 | + | defer reader.close() | |
| 1452 | + | terminal := newSupervisorTestTerminal(reader) | |
| 1453 | + | clock := newSupervisorTestClock() | |
| 1454 | + | reached := make(chan struct{}, 1) | |
| 1455 | + | connector := newSupervisorTestConnector(func(ctx context.Context, _ int) (ports.BrokerService, error) { | |
| 1456 | + | if phase == "connecting" { | |
| 1457 | + | reached <- struct{}{} | |
| 1458 | + | <-ctx.Done() | |
| 1459 | + | return nil, ctx.Err() | |
| 1460 | + | } | |
| 1461 | + | code := ports.BrokerErrorUnavailable | |
| 1462 | + | if phase == "nonretryable" { | |
| 1463 | + | code = ports.BrokerErrorIncompatible | |
| 1464 | + | } | |
| 1465 | + | return nil, ports.BrokerError{Code: code, Text: "test refusal"} | |
| 1466 | + | }) | |
| 1467 | + | s := mustSupervisor(t, SupervisorConfig{ | |
| 1468 | + | Connector: connector, Terminal: terminal, Clock: clock, | |
| 1469 | + | Picker: NewPicker(clock, 0), | |
| 1470 | + | Notify: func(State, error) { reached <- struct{}{} }, | |
| 1471 | + | }) | |
| 1472 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 1473 | + | defer cancel() | |
| 1474 | + | result := make(chan error, 1) | |
| 1475 | + | go func() { result <- s.Run(ctx) }() | |
| 1476 | + | select { | |
| 1477 | + | case <-reached: | |
| 1478 | + | case <-time.After(5 * time.Second): | |
| 1479 | + | t.Fatal("did not reach test phase") | |
| 1480 | + | } | |
| 1481 | + | reader.push([]byte{3}) | |
| 1482 | + | select { | |
| 1483 | + | case err := <-result: | |
| 1484 | + | require.NoError(t, err) | |
| 1485 | + | case <-time.After(5 * time.Second): | |
| 1486 | + | t.Fatal("Ctrl-C did not exit with stdin still open") | |
| 1487 | + | } | |
| 1488 | + | require.Equal(t, 1, terminal.restoreCount()) | |
| 1489 | + | require.True(t, clock.allStopped()) | |
| 1490 | + | }) | |
| 1491 | + | } | |
| 1492 | + | } | |
| @@ -371,6 +371,10 @@ func (s *supervisorTestService) Subscribe() (ports.BrokerSubscription, error) { | |||||
| 371 | }), nil | 371 | }), nil | ||
| 372 | } | 372 | } | ||
| 373 | 373 | ||||
| 374 | + | func (s *supervisorTestService) SubscribePreview(ports.BrokerPreviewRequest) (ports.BrokerPreviewSubscription, error) { | |||
| 375 | + | return nil, errors.New("supervisor test service does not support preview") | |||
| 376 | + | } | |||
| 377 | + | ||||
| 374 | func (s *supervisorTestService) OpenStream(ctx context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | 378 | func (s *supervisorTestService) OpenStream(ctx context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | ||
| 375 | s.mu.Lock() | 379 | s.mu.Lock() | ||
| 376 | s.openCalls = append(s.openCalls, request) | 380 | s.openCalls = append(s.openCalls, request) | ||
| @@ -1437,3 +1441,52 @@ func TestSupervisorTestHubModelsSubscriptionContract(t *testing.T) { | |||||
| 1437 | } | 1441 | } | ||
| 1438 | require.Equal(t, ports.BrokerRevision(4), hub.current().Revision) | 1442 | require.Equal(t, ports.BrokerRevision(4), hub.current().Revision) | ||
| 1439 | } | 1443 | } | ||
| 1444 | + | ||||
| 1445 | + | // Raw-mode Ctrl-C is input, not SIGINT. It must work before a broker exists, | |||
| 1446 | + | // including during a blocked connect and after permanent or transient failures. | |||
| 1447 | + | func TestSupervisorPickerCloseWhileOffline(t *testing.T) { | |||
| 1448 | + | for _, phase := range []string{"connecting", "retry", "nonretryable"} { | |||
| 1449 | + | t.Run(phase, func(t *testing.T) { | |||
| 1450 | + | reader := newPickerChunkReader() | |||
| 1451 | + | defer reader.close() | |||
| 1452 | + | terminal := newSupervisorTestTerminal(reader) | |||
| 1453 | + | clock := newSupervisorTestClock() | |||
| 1454 | + | reached := make(chan struct{}, 1) | |||
| 1455 | + | connector := newSupervisorTestConnector(func(ctx context.Context, _ int) (ports.BrokerService, error) { | |||
| 1456 | + | if phase == "connecting" { | |||
| 1457 | + | reached <- struct{}{} | |||
| 1458 | + | <-ctx.Done() | |||
| 1459 | + | return nil, ctx.Err() | |||
| 1460 | + | } | |||
| 1461 | + | code := ports.BrokerErrorUnavailable | |||
| 1462 | + | if phase == "nonretryable" { | |||
| 1463 | + | code = ports.BrokerErrorIncompatible | |||
| 1464 | + | } | |||
| 1465 | + | return nil, ports.BrokerError{Code: code, Text: "test refusal"} | |||
| 1466 | + | }) | |||
| 1467 | + | s := mustSupervisor(t, SupervisorConfig{ | |||
| 1468 | + | Connector: connector, Terminal: terminal, Clock: clock, | |||
| 1469 | + | Picker: NewPicker(clock, 0), | |||
| 1470 | + | Notify: func(State, error) { reached <- struct{}{} }, | |||
| 1471 | + | }) | |||
| 1472 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 1473 | + | defer cancel() | |||
| 1474 | + | result := make(chan error, 1) | |||
| 1475 | + | go func() { result <- s.Run(ctx) }() | |||
| 1476 | + | select { | |||
| 1477 | + | case <-reached: | |||
| 1478 | + | case <-time.After(5 * time.Second): | |||
| 1479 | + | t.Fatal("did not reach test phase") | |||
| 1480 | + | } | |||
| 1481 | + | reader.push([]byte{3}) | |||
| 1482 | + | select { | |||
| 1483 | + | case err := <-result: | |||
| 1484 | + | require.NoError(t, err) | |||
| 1485 | + | case <-time.After(5 * time.Second): | |||
| 1486 | + | t.Fatal("Ctrl-C did not exit with stdin still open") | |||
| 1487 | + | } | |||
| 1488 | + | require.Equal(t, 1, terminal.restoreCount()) | |||
| 1489 | + | require.True(t, clock.allStopped()) | |||
| 1490 | + | }) | |||
| 1491 | + | } | |||
| 1492 | + | } | |||
M internal/usecase/client/wire_external_test_helpers_test.go +0 -6
| @@ -110,16 +110,12 @@ func clientMessageName(t *testing.T, envelope wire.Envelope) string { | |||
| 110 | 110 | return "NavigationInventoryPublication" | |
| 111 | 111 | case protocol.NavigationInventoryFailure: | |
| 112 | 112 | return "NavigationInventoryFailure" | |
| 113 | - | case protocol.PickerBegin: | |
| 114 | - | return "PickerBegin" | |
| 115 | 113 | case protocol.PickerClose: | |
| 116 | 114 | return "PickerClose" | |
| 117 | 115 | case protocol.PickerSelection: | |
| 118 | 116 | return "PickerSelection" | |
| 119 | 117 | case protocol.PickerPreviewRequest: | |
| 120 | 118 | return "PickerPreviewRequest" | |
| 121 | - | case protocol.PickerControlRequest: | |
| 122 | - | return "PickerControlRequest" | |
| 123 | 119 | default: | |
| 124 | 120 | return "" | |
| 125 | 121 | } | |
| @@ -189,8 +185,6 @@ func serverMessageNameForTest(payload []byte) string { | |||
| 189 | 185 | return "PickerFailure" | |
| 190 | 186 | case protocol.PickerPreview: | |
| 191 | 187 | return "PickerPreview" | |
| 192 | - | case protocol.PickerControlResponse: | |
| 193 | - | return "PickerControlResponse" | |
| 194 | 188 | default: | |
| 195 | 189 | return "" | |
| 196 | 190 | } | |
| @@ -110,16 +110,12 @@ func clientMessageName(t *testing.T, envelope wire.Envelope) string { | |||||
| 110 | return "NavigationInventoryPublication" | 110 | return "NavigationInventoryPublication" | ||
| 111 | case protocol.NavigationInventoryFailure: | 111 | case protocol.NavigationInventoryFailure: | ||
| 112 | return "NavigationInventoryFailure" | 112 | return "NavigationInventoryFailure" | ||
| 113 | - | case protocol.PickerBegin: | |||
| 114 | - | return "PickerBegin" | |||
| 115 | case protocol.PickerClose: | 113 | case protocol.PickerClose: | ||
| 116 | return "PickerClose" | 114 | return "PickerClose" | ||
| 117 | case protocol.PickerSelection: | 115 | case protocol.PickerSelection: | ||
| 118 | return "PickerSelection" | 116 | return "PickerSelection" | ||
| 119 | case protocol.PickerPreviewRequest: | 117 | case protocol.PickerPreviewRequest: | ||
| 120 | return "PickerPreviewRequest" | 118 | return "PickerPreviewRequest" | ||
| 121 | - | case protocol.PickerControlRequest: | |||
| 122 | - | return "PickerControlRequest" | |||
| 123 | default: | 119 | default: | ||
| 124 | return "" | 120 | return "" | ||
| 125 | } | 121 | } | ||
| @@ -189,8 +185,6 @@ func serverMessageNameForTest(payload []byte) string { | |||||
| 189 | return "PickerFailure" | 185 | return "PickerFailure" | ||
| 190 | case protocol.PickerPreview: | 186 | case protocol.PickerPreview: | ||
| 191 | return "PickerPreview" | 187 | return "PickerPreview" | ||
| 192 | - | case protocol.PickerControlResponse: | |||
| 193 | - | return "PickerControlResponse" | |||
| 194 | default: | 188 | default: | ||
| 195 | return "" | 189 | return "" | ||
| 196 | } | 190 | } | ||
M internal/usecase/picker/modal.go +8 -0
| @@ -28,3 +28,11 @@ func PreviewRect(terminal domain.Size) domain.Rect { | |||
| 28 | 28 | func Size(rect domain.Rect) domain.Size { | |
| 29 | 29 | return domain.Size{Cols: rect.Width, Rows: rect.Height} | |
| 30 | 30 | } | |
| 31 | + | ||
| 32 | + | // PreviewSize returns the preview viewport for one terminal size: the preview | |
| 33 | + | // pane inside the shared modal geometry. A zero size means this terminal has | |
| 34 | + | // no room for a preview. The serving daemon and the presenting client both | |
| 35 | + | // derive it from PreviewRect, so the captured viewport always fits its pane. | |
| 36 | + | func PreviewSize(terminal domain.Size) domain.Size { | |
| 37 | + | return Size(PreviewRect(terminal)) | |
| 38 | + | } | |
| @@ -28,3 +28,11 @@ func PreviewRect(terminal domain.Size) domain.Rect { | |||||
| 28 | func Size(rect domain.Rect) domain.Size { | 28 | func Size(rect domain.Rect) domain.Size { | ||
| 29 | return domain.Size{Cols: rect.Width, Rows: rect.Height} | 29 | return domain.Size{Cols: rect.Width, Rows: rect.Height} | ||
| 30 | } | 30 | } | ||
| 31 | + | ||||
| 32 | + | // PreviewSize returns the preview viewport for one terminal size: the preview | |||
| 33 | + | // pane inside the shared modal geometry. A zero size means this terminal has | |||
| 34 | + | // no room for a preview. The serving daemon and the presenting client both | |||
| 35 | + | // derive it from PreviewRect, so the captured viewport always fits its pane. | |||
| 36 | + | func PreviewSize(terminal domain.Size) domain.Size { | |||
| 37 | + | return Size(PreviewRect(terminal)) | |||
| 38 | + | } | |||
M internal/usecase/picker/model.go +7 -1
| @@ -508,7 +508,7 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render | |||
| 508 | 508 | name = " " + name | |
| 509 | 509 | } | |
| 510 | 510 | badge := "" | |
| 511 | - | if r.rendersAsHeader() { | |
| 511 | + | if r.rendersAsHeader() && !(r.kind() == protocol.PickerLineSession && r.line.Status == protocol.PickerLineStatusUp) { | |
| 512 | 512 | badge = lineStatusBadge(r.line.Status) | |
| 513 | 513 | } | |
| 514 | 514 | contentClipX := clipX | |
| @@ -535,6 +535,9 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render | |||
| 535 | 535 | continue | |
| 536 | 536 | } | |
| 537 | 537 | if r.rendersAsHeader() { | |
| 538 | + | if r.line.Detail != "" && x < contentClipX { | |
| 539 | + | x = ui.DrawText(frame, x, rect.Y+y, contentClipX, " ", detailStyle) | |
| 540 | + | } | |
| 538 | 541 | detail := ui.TruncateText(r.line.Detail, contentClipX-x) | |
| 539 | 542 | detailPositions := visibleMatchPositions(m.matchPositions(idx, matchDetail), detail, detail != r.line.Detail) | |
| 540 | 543 | drawMatchedText(frame, x, rect.Y+y, contentClipX, detail, detailStyle, nameMatchStyle, detailPositions) | |
| @@ -549,6 +552,9 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render | |||
| 549 | 552 | x = ui.DrawText(frame, x, rect.Y+y, clipX, " "+string(ui.AttentionGlyph), base) | |
| 550 | 553 | } | |
| 551 | 554 | ||
| 555 | + | if r.line.Detail != "" && x < clipX { | |
| 556 | + | x = ui.DrawText(frame, x, rect.Y+y, clipX, " ", detailStyle) | |
| 557 | + | } | |
| 552 | 558 | detail := ui.TruncateText(r.line.Detail, clipX-x) | |
| 553 | 559 | detailPositions := visibleMatchPositions(m.matchPositions(idx, matchDetail), detail, detail != r.line.Detail) | |
| 554 | 560 | drawMatchedText(frame, x, rect.Y+y, clipX, detail, detailStyle, nameMatchStyle, detailPositions) | |
| @@ -508,7 +508,7 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render | |||||
| 508 | name = " " + name | 508 | name = " " + name | ||
| 509 | } | 509 | } | ||
| 510 | badge := "" | 510 | badge := "" | ||
| 511 | - | if r.rendersAsHeader() { | 511 | + | if r.rendersAsHeader() && !(r.kind() == protocol.PickerLineSession && r.line.Status == protocol.PickerLineStatusUp) { |
| 512 | badge = lineStatusBadge(r.line.Status) | 512 | badge = lineStatusBadge(r.line.Status) | ||
| 513 | } | 513 | } | ||
| 514 | contentClipX := clipX | 514 | contentClipX := clipX | ||
| @@ -535,6 +535,9 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render | |||||
| 535 | continue | 535 | continue | ||
| 536 | } | 536 | } | ||
| 537 | if r.rendersAsHeader() { | 537 | if r.rendersAsHeader() { | ||
| 538 | + | if r.line.Detail != "" && x < contentClipX { | |||
| 539 | + | x = ui.DrawText(frame, x, rect.Y+y, contentClipX, " ", detailStyle) | |||
| 540 | + | } | |||
| 538 | detail := ui.TruncateText(r.line.Detail, contentClipX-x) | 541 | detail := ui.TruncateText(r.line.Detail, contentClipX-x) | ||
| 539 | detailPositions := visibleMatchPositions(m.matchPositions(idx, matchDetail), detail, detail != r.line.Detail) | 542 | detailPositions := visibleMatchPositions(m.matchPositions(idx, matchDetail), detail, detail != r.line.Detail) | ||
| 540 | drawMatchedText(frame, x, rect.Y+y, contentClipX, detail, detailStyle, nameMatchStyle, detailPositions) | 543 | drawMatchedText(frame, x, rect.Y+y, contentClipX, detail, detailStyle, nameMatchStyle, detailPositions) | ||
| @@ -549,6 +552,9 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render | |||||
| 549 | x = ui.DrawText(frame, x, rect.Y+y, clipX, " "+string(ui.AttentionGlyph), base) | 552 | x = ui.DrawText(frame, x, rect.Y+y, clipX, " "+string(ui.AttentionGlyph), base) | ||
| 550 | } | 553 | } | ||
| 551 | 554 | ||||
| 555 | + | if r.line.Detail != "" && x < clipX { | |||
| 556 | + | x = ui.DrawText(frame, x, rect.Y+y, clipX, " ", detailStyle) | |||
| 557 | + | } | |||
| 552 | detail := ui.TruncateText(r.line.Detail, clipX-x) | 558 | detail := ui.TruncateText(r.line.Detail, clipX-x) | ||
| 553 | detailPositions := visibleMatchPositions(m.matchPositions(idx, matchDetail), detail, detail != r.line.Detail) | 559 | detailPositions := visibleMatchPositions(m.matchPositions(idx, matchDetail), detail, detail != r.line.Detail) | ||
| 554 | drawMatchedText(frame, x, rect.Y+y, clipX, detail, detailStyle, nameMatchStyle, detailPositions) | 560 | drawMatchedText(frame, x, rect.Y+y, clipX, detail, detailStyle, nameMatchStyle, detailPositions) | ||
M internal/usecase/picker/model_test.go +1 -1
| @@ -297,7 +297,7 @@ func TestRenderDrawsStatusBadgesAndStoppedRows(t *testing.T) { | |||
| 297 | 297 | frame := m.Render(domain.Size{Cols: 60, Rows: 8}, Preview{}) | |
| 298 | 298 | require.Equal(t, 60, frame.Width) | |
| 299 | 299 | require.Equal(t, 8, frame.Height) | |
| 300 | - | require.Contains(t, rowText(frame.Row(0)), "[up]") | |
| 300 | + | require.NotContains(t, rowText(frame.Row(0)), "[up]") | |
| 301 | 301 | require.Contains(t, rowText(frame.Row(1)), "[stopped]") | |
| 302 | 302 | } | |
| 303 | 303 | ||
| @@ -297,7 +297,7 @@ func TestRenderDrawsStatusBadgesAndStoppedRows(t *testing.T) { | |||||
| 297 | frame := m.Render(domain.Size{Cols: 60, Rows: 8}, Preview{}) | 297 | frame := m.Render(domain.Size{Cols: 60, Rows: 8}, Preview{}) | ||
| 298 | require.Equal(t, 60, frame.Width) | 298 | require.Equal(t, 60, frame.Width) | ||
| 299 | require.Equal(t, 8, frame.Height) | 299 | require.Equal(t, 8, frame.Height) | ||
| 300 | - | require.Contains(t, rowText(frame.Row(0)), "[up]") | 300 | + | require.NotContains(t, rowText(frame.Row(0)), "[up]") |
| 301 | require.Contains(t, rowText(frame.Row(1)), "[stopped]") | 301 | require.Contains(t, rowText(frame.Row(1)), "[stopped]") | ||
| 302 | } | 302 | } | ||
| 303 | 303 | ||||