fix(client): settle ui-driver actions across the picker overlay
The client picker composed over a live attachment published an unattached Picker context and suppressed attachment output, so the action that opened it never matched its daemon receipt, and later driver actions were refused or never settled. - The overlay frame keeps the live attachment's context (UI.OverlayContext). - Suppressed attachment output records its committed boundary, so a receipt fenced on it settles its action. - Input the navigation picker consumes is settled by the supervisor at its event-loop boundary; a commit elsewhere follows the action across the swap to the destination's first publication. - The swap allocates its stream identity at open time: route streams opened meanwhile could move the anti-replay window past one reserved at commit, refusing the swap as stale.
- commit
- 87a5a2ac755b211f421373de58ebdc17793ff669
- author
- brice · · 2026-09-23 02:57 UTC
- parent
- 2e0d9a3
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
11 files · +371 -19
M internal/app/broker_client.go +8 -1
| @@ -148,8 +148,15 @@ func (p *brokerClientPresentation) Render(state client.State) { | |||
| 148 | 148 | } | |
| 149 | 149 | transaction, _ := p.terminal.(ports.UIOutputTransaction) | |
| 150 | 150 | context := p.pickerContext() | |
| 151 | - | if state.Presentation == client.PresentConnecting { | |
| 151 | + | switch state.Presentation { | |
| 152 | + | case client.PresentConnecting: | |
| 152 | 153 | context.Status = ports.UIStatusConnecting | |
| 154 | + | case client.PresentAttachedPicker: | |
| 155 | + | // The picker is composed over a live attachment: the attachment stays | |
| 156 | + | // the actionable generation, so the frame keeps its attached context. | |
| 157 | + | if attached, ok := p.ui.OverlayContext(); ok { | |
| 158 | + | context = attached | |
| 159 | + | } | |
| 153 | 160 | } | |
| 154 | 161 | if transaction != nil { | |
| 155 | 162 | transaction.BeginOutput(context) | |
| @@ -148,8 +148,15 @@ func (p *brokerClientPresentation) Render(state client.State) { | |||||
| 148 | } | 148 | } | ||
| 149 | transaction, _ := p.terminal.(ports.UIOutputTransaction) | 149 | transaction, _ := p.terminal.(ports.UIOutputTransaction) | ||
| 150 | context := p.pickerContext() | 150 | context := p.pickerContext() | ||
| 151 | - | if state.Presentation == client.PresentConnecting { | 151 | + | switch state.Presentation { |
| 152 | + | case client.PresentConnecting: | |||
| 152 | context.Status = ports.UIStatusConnecting | 153 | context.Status = ports.UIStatusConnecting | ||
| 154 | + | case client.PresentAttachedPicker: | |||
| 155 | + | // The picker is composed over a live attachment: the attachment stays | |||
| 156 | + | // the actionable generation, so the frame keeps its attached context. | |||
| 157 | + | if attached, ok := p.ui.OverlayContext(); ok { | |||
| 158 | + | context = attached | |||
| 159 | + | } | |||
| 153 | } | 160 | } | ||
| 154 | if transaction != nil { | 161 | if transaction != nil { | ||
| 155 | transaction.BeginOutput(context) | 162 | transaction.BeginOutput(context) | ||
M internal/usecase/client/attachment_input.go +1 -1
| @@ -281,7 +281,7 @@ func (in *attachmentInput) route(ctx context.Context, state outputApplyState, da | |||
| 281 | 281 | if len(data) == 0 { | |
| 282 | 282 | return nil | |
| 283 | 283 | } | |
| 284 | - | consumed, err := in.picker.consumeInput(ctx, state, AttachmentInputEvent{Data: data}) | |
| 284 | + | consumed, _, err := in.picker.consumeInput(ctx, state, AttachmentInputEvent{Data: data}) | |
| 285 | 285 | if err != nil || consumed { | |
| 286 | 286 | return err | |
| 287 | 287 | } | |
| @@ -281,7 +281,7 @@ func (in *attachmentInput) route(ctx context.Context, state outputApplyState, da | |||||
| 281 | if len(data) == 0 { | 281 | if len(data) == 0 { | ||
| 282 | return nil | 282 | return nil | ||
| 283 | } | 283 | } | ||
| 284 | - | consumed, err := in.picker.consumeInput(ctx, state, AttachmentInputEvent{Data: data}) | 284 | + | consumed, _, err := in.picker.consumeInput(ctx, state, AttachmentInputEvent{Data: data}) |
| 285 | if err != nil || consumed { | 285 | if err != nil || consumed { | ||
| 286 | return err | 286 | return err | ||
| 287 | } | 287 | } | ||
M internal/usecase/client/attachment_session.go +5 -4
| @@ -480,17 +480,18 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||
| 480 | 480 | fg.PreserveInput(event.input.Data) | |
| 481 | 481 | return w.settle(ctx, fg, stream, token, err) | |
| 482 | 482 | } | |
| 483 | - | consumed, err := picker.consumeInput(ctx, state, *event.input) | |
| 483 | + | consumed, supervised, err := picker.consumeInput(ctx, state, *event.input) | |
| 484 | 484 | if err != nil { | |
| 485 | 485 | fg.PreserveInput(event.input.Data) | |
| 486 | 486 | return w.settle(ctx, fg, stream, token, err) | |
| 487 | 487 | } | |
| 488 | 488 | if consumed { | |
| 489 | 489 | // The overlay owned these bytes: nothing reaches the session. | |
| 490 | - | // A driver action still fences through the daemon so it | |
| 491 | - | // completes after the overlay applied it. | |
| 490 | + | // A move-picker action still fences through the daemon so it | |
| 491 | + | // completes after the overlay applied it; the supervisor | |
| 492 | + | // settles a navigation-picker action itself. | |
| 492 | 493 | fg.AckInput() | |
| 493 | - | if event.input.actionID != 0 { | |
| 494 | + | if event.input.actionID != 0 && !supervised { | |
| 494 | 495 | if err := w.send(ctx, fg, stream, protocol.UIFence{ActionID: event.input.actionID}); err != nil { | |
| 495 | 496 | return w.settle(ctx, fg, stream, token, err) | |
| 496 | 497 | } | |
| @@ -480,17 +480,18 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||||
| 480 | fg.PreserveInput(event.input.Data) | 480 | fg.PreserveInput(event.input.Data) | ||
| 481 | return w.settle(ctx, fg, stream, token, err) | 481 | return w.settle(ctx, fg, stream, token, err) | ||
| 482 | } | 482 | } | ||
| 483 | - | consumed, err := picker.consumeInput(ctx, state, *event.input) | 483 | + | consumed, supervised, err := picker.consumeInput(ctx, state, *event.input) |
| 484 | if err != nil { | 484 | if err != nil { | ||
| 485 | fg.PreserveInput(event.input.Data) | 485 | fg.PreserveInput(event.input.Data) | ||
| 486 | return w.settle(ctx, fg, stream, token, err) | 486 | return w.settle(ctx, fg, stream, token, err) | ||
| 487 | } | 487 | } | ||
| 488 | if consumed { | 488 | if consumed { | ||
| 489 | // The overlay owned these bytes: nothing reaches the session. | 489 | // The overlay owned these bytes: nothing reaches the session. | ||
| 490 | - | // A driver action still fences through the daemon so it | 490 | + | // A move-picker action still fences through the daemon so it |
| 491 | - | // completes after the overlay applied it. | 491 | + | // completes after the overlay applied it; the supervisor |
| 492 | + | // settles a navigation-picker action itself. | |||
| 492 | fg.AckInput() | 493 | fg.AckInput() | ||
| 493 | - | if event.input.actionID != 0 { | 494 | + | if event.input.actionID != 0 && !supervised { |
| 494 | if err := w.send(ctx, fg, stream, protocol.UIFence{ActionID: event.input.actionID}); err != nil { | 495 | if err := w.send(ctx, fg, stream, protocol.UIFence{ActionID: event.input.actionID}); err != nil { | ||
| 495 | return w.settle(ctx, fg, stream, token, err) | 496 | return w.settle(ctx, fg, stream, token, err) | ||
| 496 | } | 497 | } | ||
M internal/usecase/client/attachment_worker.go +56 -3
| @@ -393,6 +393,9 @@ type attachmentHost struct { | |||
| 393 | 393 | // (Plan 003 C4/C5). Only the supervisor consumes them. | |
| 394 | 394 | routeDemand chan struct{} | |
| 395 | 395 | navigations chan protocol.ServerMessage | |
| 396 | + | // overlayActions carries driver actions the client picker overlay | |
| 397 | + | // consumed to the supervisor, which settles them. | |
| 398 | + | overlayActions chan overlayActionConsumed | |
| 396 | 399 | } | |
| 397 | 400 | ||
| 398 | 401 | // newAttachmentHost builds the reusable foreground host. A nil clock falls back | |
| @@ -434,6 +437,7 @@ func newAttachmentHost(cfg attachmentHostConfig) *attachmentHost { | |||
| 434 | 437 | internalActions: make(chan AttachmentLifecycleAction, 1), | |
| 435 | 438 | routeDemand: make(chan struct{}, 1), | |
| 436 | 439 | navigations: make(chan protocol.ServerMessage, 1), | |
| 440 | + | overlayActions: make(chan overlayActionConsumed, 1), | |
| 437 | 441 | } | |
| 438 | 442 | } | |
| 439 | 443 | ||
| @@ -619,6 +623,7 @@ func (h *attachmentHost) drainForegroundSignals() { | |||
| 619 | 623 | case <-h.internalActions: | |
| 620 | 624 | case <-h.routeDemand: | |
| 621 | 625 | case <-h.navigations: | |
| 626 | + | case <-h.overlayActions: | |
| 622 | 627 | default: | |
| 623 | 628 | return | |
| 624 | 629 | } | |
| @@ -748,7 +753,7 @@ type attachmentOverlayForeground interface { | |||
| 748 | 753 | setOverlay(kind attachmentOverlayKind, sink pickerInputConsumer) bool | |
| 749 | 754 | clearOverlay(kind attachmentOverlayKind) | |
| 750 | 755 | overlayKind() attachmentOverlayKind | |
| 751 | - | divertInput(data []byte) bool | |
| 756 | + | divertInput(data []byte, actionID uint64) bool | |
| 752 | 757 | overlayRepaint() <-chan struct{} | |
| 753 | 758 | overlayOutput(uiContext ports.UIContext, data []byte) error | |
| 754 | 759 | noteCommitted(target protocol.ExactSessionTarget, tab domain.TabStableID) | |
| @@ -844,8 +849,11 @@ func (f *attachmentForeground) overlayKind() attachmentOverlayKind { | |||
| 844 | 849 | ||
| 845 | 850 | // divertInput hands one authorized delivery to the supervisor-owned overlay. | |
| 846 | 851 | // It reports false when no navigation overlay owns input, so the worker keeps | |
| 847 | - | // its session path. | |
| 848 | - | func (f *attachmentForeground) divertInput(data []byte) bool { | |
| 852 | + | // its session path. A driver action consumed here never reaches the daemon, so | |
| 853 | + | // it is marked before the picker sees the bytes and handed to the supervisor | |
| 854 | + | // afterwards, which settles it at its own event-loop boundary (or follows it | |
| 855 | + | // across the swap a commit starts). | |
| 856 | + | func (f *attachmentForeground) divertInput(data []byte, actionID uint64) bool { | |
| 849 | 857 | if f == nil { | |
| 850 | 858 | return false | |
| 851 | 859 | } | |
| @@ -856,10 +864,50 @@ func (f *attachmentForeground) divertInput(data []byte) bool { | |||
| 856 | 864 | if kind != attachmentOverlayNavigation || sink == nil { | |
| 857 | 865 | return false | |
| 858 | 866 | } | |
| 867 | + | tracked := actionID != 0 && f.uiGeneration != 0 && f.host != nil && f.host.actionUI != nil | |
| 868 | + | if tracked { | |
| 869 | + | f.host.actionUI.markOverlayInput(f.uiGeneration, actionID) | |
| 870 | + | } | |
| 859 | 871 | sink.ConsumeTerminalRead(data) | |
| 872 | + | if tracked { | |
| 873 | + | f.host.offerOverlayAction(overlayActionConsumed{generation: f.uiGeneration, actionID: actionID}) | |
| 874 | + | } | |
| 860 | 875 | return true | |
| 861 | 876 | } | |
| 862 | 877 | ||
| 878 | + | // overlayActionConsumed names one driver action the client picker overlay | |
| 879 | + | // consumed on the given UI generation. | |
| 880 | + | type overlayActionConsumed struct { | |
| 881 | + | generation uint64 | |
| 882 | + | actionID uint64 | |
| 883 | + | } | |
| 884 | + | ||
| 885 | + | // offerOverlayAction hands the latest overlay-consumed action to the | |
| 886 | + | // supervisor. Automation admits one action at a time and the worker is the only | |
| 887 | + | // producer, so replacing an unread stale entry never drops a live one. | |
| 888 | + | func (h *attachmentHost) offerOverlayAction(consumed overlayActionConsumed) { | |
| 889 | + | for { | |
| 890 | + | select { | |
| 891 | + | case h.overlayActions <- consumed: | |
| 892 | + | return | |
| 893 | + | default: | |
| 894 | + | } | |
| 895 | + | select { | |
| 896 | + | case <-h.overlayActions: | |
| 897 | + | default: | |
| 898 | + | } | |
| 899 | + | } | |
| 900 | + | } | |
| 901 | + | ||
| 902 | + | // OverlayActions wakes the supervisor for each driver action the picker | |
| 903 | + | // overlay consumed. | |
| 904 | + | func (h *attachmentHost) OverlayActions() <-chan overlayActionConsumed { | |
| 905 | + | if h == nil { | |
| 906 | + | return nil | |
| 907 | + | } | |
| 908 | + | return h.overlayActions | |
| 909 | + | } | |
| 910 | + | ||
| 863 | 911 | func (f *attachmentForeground) overlayRepaint() <-chan struct{} { | |
| 864 | 912 | if f == nil { | |
| 865 | 913 | return nil | |
| @@ -1380,6 +1428,11 @@ func (f *attachmentForeground) write(uiContext ports.UIContext, data []byte, ove | |||
| 1380 | 1428 | return false | |
| 1381 | 1429 | } | |
| 1382 | 1430 | if !overlay && f.overlayKind() != attachmentOverlayNone { | |
| 1431 | + | // Applied, not written: remember its committed boundary so a | |
| 1432 | + | // daemon receipt fenced on it still settles its action. | |
| 1433 | + | if f.uiGeneration != 0 && f.host != nil && f.host.actionUI != nil { | |
| 1434 | + | f.host.actionUI.suppressedOutput(f.uiGeneration, f.presentationContext(uiContext)) | |
| 1435 | + | } | |
| 1383 | 1436 | return true | |
| 1384 | 1437 | } | |
| 1385 | 1438 | if supervisorNil(f.term) { | |
| @@ -393,6 +393,9 @@ type attachmentHost struct { | |||||
| 393 | // (Plan 003 C4/C5). Only the supervisor consumes them. | 393 | // (Plan 003 C4/C5). Only the supervisor consumes them. | ||
| 394 | routeDemand chan struct{} | 394 | routeDemand chan struct{} | ||
| 395 | navigations chan protocol.ServerMessage | 395 | navigations chan protocol.ServerMessage | ||
| 396 | + | // overlayActions carries driver actions the client picker overlay | |||
| 397 | + | // consumed to the supervisor, which settles them. | |||
| 398 | + | overlayActions chan overlayActionConsumed | |||
| 396 | } | 399 | } | ||
| 397 | 400 | ||||
| 398 | // newAttachmentHost builds the reusable foreground host. A nil clock falls back | 401 | // newAttachmentHost builds the reusable foreground host. A nil clock falls back | ||
| @@ -434,6 +437,7 @@ func newAttachmentHost(cfg attachmentHostConfig) *attachmentHost { | |||||
| 434 | internalActions: make(chan AttachmentLifecycleAction, 1), | 437 | internalActions: make(chan AttachmentLifecycleAction, 1), | ||
| 435 | routeDemand: make(chan struct{}, 1), | 438 | routeDemand: make(chan struct{}, 1), | ||
| 436 | navigations: make(chan protocol.ServerMessage, 1), | 439 | navigations: make(chan protocol.ServerMessage, 1), | ||
| 440 | + | overlayActions: make(chan overlayActionConsumed, 1), | |||
| 437 | } | 441 | } | ||
| 438 | } | 442 | } | ||
| 439 | 443 | ||||
| @@ -619,6 +623,7 @@ func (h *attachmentHost) drainForegroundSignals() { | |||||
| 619 | case <-h.internalActions: | 623 | case <-h.internalActions: | ||
| 620 | case <-h.routeDemand: | 624 | case <-h.routeDemand: | ||
| 621 | case <-h.navigations: | 625 | case <-h.navigations: | ||
| 626 | + | case <-h.overlayActions: | |||
| 622 | default: | 627 | default: | ||
| 623 | return | 628 | return | ||
| 624 | } | 629 | } | ||
| @@ -748,7 +753,7 @@ type attachmentOverlayForeground interface { | |||||
| 748 | setOverlay(kind attachmentOverlayKind, sink pickerInputConsumer) bool | 753 | setOverlay(kind attachmentOverlayKind, sink pickerInputConsumer) bool | ||
| 749 | clearOverlay(kind attachmentOverlayKind) | 754 | clearOverlay(kind attachmentOverlayKind) | ||
| 750 | overlayKind() attachmentOverlayKind | 755 | overlayKind() attachmentOverlayKind | ||
| 751 | - | divertInput(data []byte) bool | 756 | + | divertInput(data []byte, actionID uint64) bool |
| 752 | overlayRepaint() <-chan struct{} | 757 | overlayRepaint() <-chan struct{} | ||
| 753 | overlayOutput(uiContext ports.UIContext, data []byte) error | 758 | overlayOutput(uiContext ports.UIContext, data []byte) error | ||
| 754 | noteCommitted(target protocol.ExactSessionTarget, tab domain.TabStableID) | 759 | noteCommitted(target protocol.ExactSessionTarget, tab domain.TabStableID) | ||
| @@ -844,8 +849,11 @@ func (f *attachmentForeground) overlayKind() attachmentOverlayKind { | |||||
| 844 | 849 | ||||
| 845 | // divertInput hands one authorized delivery to the supervisor-owned overlay. | 850 | // divertInput hands one authorized delivery to the supervisor-owned overlay. | ||
| 846 | // It reports false when no navigation overlay owns input, so the worker keeps | 851 | // It reports false when no navigation overlay owns input, so the worker keeps | ||
| 847 | - | // its session path. | 852 | + | // its session path. A driver action consumed here never reaches the daemon, so |
| 848 | - | func (f *attachmentForeground) divertInput(data []byte) bool { | 853 | + | // it is marked before the picker sees the bytes and handed to the supervisor |
| 854 | + | // afterwards, which settles it at its own event-loop boundary (or follows it | |||
| 855 | + | // across the swap a commit starts). | |||
| 856 | + | func (f *attachmentForeground) divertInput(data []byte, actionID uint64) bool { | |||
| 849 | if f == nil { | 857 | if f == nil { | ||
| 850 | return false | 858 | return false | ||
| 851 | } | 859 | } | ||
| @@ -856,10 +864,50 @@ func (f *attachmentForeground) divertInput(data []byte) bool { | |||||
| 856 | if kind != attachmentOverlayNavigation || sink == nil { | 864 | if kind != attachmentOverlayNavigation || sink == nil { | ||
| 857 | return false | 865 | return false | ||
| 858 | } | 866 | } | ||
| 867 | + | tracked := actionID != 0 && f.uiGeneration != 0 && f.host != nil && f.host.actionUI != nil | |||
| 868 | + | if tracked { | |||
| 869 | + | f.host.actionUI.markOverlayInput(f.uiGeneration, actionID) | |||
| 870 | + | } | |||
| 859 | sink.ConsumeTerminalRead(data) | 871 | sink.ConsumeTerminalRead(data) | ||
| 872 | + | if tracked { | |||
| 873 | + | f.host.offerOverlayAction(overlayActionConsumed{generation: f.uiGeneration, actionID: actionID}) | |||
| 874 | + | } | |||
| 860 | return true | 875 | return true | ||
| 861 | } | 876 | } | ||
| 862 | 877 | ||||
| 878 | + | // overlayActionConsumed names one driver action the client picker overlay | |||
| 879 | + | // consumed on the given UI generation. | |||
| 880 | + | type overlayActionConsumed struct { | |||
| 881 | + | generation uint64 | |||
| 882 | + | actionID uint64 | |||
| 883 | + | } | |||
| 884 | + | ||||
| 885 | + | // offerOverlayAction hands the latest overlay-consumed action to the | |||
| 886 | + | // supervisor. Automation admits one action at a time and the worker is the only | |||
| 887 | + | // producer, so replacing an unread stale entry never drops a live one. | |||
| 888 | + | func (h *attachmentHost) offerOverlayAction(consumed overlayActionConsumed) { | |||
| 889 | + | for { | |||
| 890 | + | select { | |||
| 891 | + | case h.overlayActions <- consumed: | |||
| 892 | + | return | |||
| 893 | + | default: | |||
| 894 | + | } | |||
| 895 | + | select { | |||
| 896 | + | case <-h.overlayActions: | |||
| 897 | + | default: | |||
| 898 | + | } | |||
| 899 | + | } | |||
| 900 | + | } | |||
| 901 | + | ||||
| 902 | + | // OverlayActions wakes the supervisor for each driver action the picker | |||
| 903 | + | // overlay consumed. | |||
| 904 | + | func (h *attachmentHost) OverlayActions() <-chan overlayActionConsumed { | |||
| 905 | + | if h == nil { | |||
| 906 | + | return nil | |||
| 907 | + | } | |||
| 908 | + | return h.overlayActions | |||
| 909 | + | } | |||
| 910 | + | ||||
| 863 | func (f *attachmentForeground) overlayRepaint() <-chan struct{} { | 911 | func (f *attachmentForeground) overlayRepaint() <-chan struct{} { | ||
| 864 | if f == nil { | 912 | if f == nil { | ||
| 865 | return nil | 913 | return nil | ||
| @@ -1380,6 +1428,11 @@ func (f *attachmentForeground) write(uiContext ports.UIContext, data []byte, ove | |||||
| 1380 | return false | 1428 | return false | ||
| 1381 | } | 1429 | } | ||
| 1382 | if !overlay && f.overlayKind() != attachmentOverlayNone { | 1430 | if !overlay && f.overlayKind() != attachmentOverlayNone { | ||
| 1431 | + | // Applied, not written: remember its committed boundary so a | |||
| 1432 | + | // daemon receipt fenced on it still settles its action. | |||
| 1433 | + | if f.uiGeneration != 0 && f.host != nil && f.host.actionUI != nil { | |||
| 1434 | + | f.host.actionUI.suppressedOutput(f.uiGeneration, f.presentationContext(uiContext)) | |||
| 1435 | + | } | |||
| 1383 | return true | 1436 | return true | ||
| 1384 | } | 1437 | } | ||
| 1385 | if supervisorNil(f.term) { | 1438 | if supervisorNil(f.term) { | ||
M internal/usecase/client/move_picker_overlay.go +9 -7
| @@ -223,21 +223,23 @@ func (p *attachmentMovePicker) release() { | |||
| 223 | 223 | } | |
| 224 | 224 | ||
| 225 | 225 | // consumeInput offers one authorized delivery to the overlay that owns input. | |
| 226 | - | // It reports true when the bytes were consumed and must not reach the session. | |
| 227 | - | func (p *attachmentMovePicker) consumeInput(ctx context.Context, state outputApplyState, input AttachmentInputEvent) (bool, error) { | |
| 226 | + | // It reports consumed when the bytes must not reach the session, and | |
| 227 | + | // supervised when the supervisor-owned navigation picker consumed them: its | |
| 228 | + | // action is then settled by the supervisor, never fenced through the daemon. | |
| 229 | + | func (p *attachmentMovePicker) consumeInput(ctx context.Context, state outputApplyState, input AttachmentInputEvent) (consumed, supervised bool, err error) { | |
| 228 | 230 | if p.move.presenting { | |
| 229 | 231 | p.stopEscape() | |
| 230 | 232 | op, changed := p.move.input(input.Data, input.actionID) | |
| 231 | 233 | if err := p.applyOp(ctx, state, op, changed); err != nil { | |
| 232 | - | return true, err | |
| 234 | + | return true, false, err | |
| 233 | 235 | } | |
| 234 | 236 | p.armEscape() | |
| 235 | - | return true, nil | |
| 237 | + | return true, false, nil | |
| 236 | 238 | } | |
| 237 | - | if p.overlay != nil && p.overlay.divertInput(input.Data) { | |
| 238 | - | return true, nil | |
| 239 | + | if p.overlay != nil && p.overlay.divertInput(input.Data, input.actionID) { | |
| 240 | + | return true, true, nil | |
| 239 | 241 | } | |
| 240 | - | return false, nil | |
| 242 | + | return false, false, nil | |
| 241 | 243 | } | |
| 242 | 244 | ||
| 243 | 245 | // applyOp carries out one decoded decision: close sends the typed close and | |
| @@ -223,21 +223,23 @@ func (p *attachmentMovePicker) release() { | |||||
| 223 | } | 223 | } | ||
| 224 | 224 | ||||
| 225 | // consumeInput offers one authorized delivery to the overlay that owns input. | 225 | // consumeInput offers one authorized delivery to the overlay that owns input. | ||
| 226 | - | // It reports true when the bytes were consumed and must not reach the session. | 226 | + | // It reports consumed when the bytes must not reach the session, and |
| 227 | - | func (p *attachmentMovePicker) consumeInput(ctx context.Context, state outputApplyState, input AttachmentInputEvent) (bool, error) { | 227 | + | // supervised when the supervisor-owned navigation picker consumed them: its |
| 228 | + | // action is then settled by the supervisor, never fenced through the daemon. | |||
| 229 | + | func (p *attachmentMovePicker) consumeInput(ctx context.Context, state outputApplyState, input AttachmentInputEvent) (consumed, supervised bool, err error) { | |||
| 228 | if p.move.presenting { | 230 | if p.move.presenting { | ||
| 229 | p.stopEscape() | 231 | p.stopEscape() | ||
| 230 | op, changed := p.move.input(input.Data, input.actionID) | 232 | op, changed := p.move.input(input.Data, input.actionID) | ||
| 231 | if err := p.applyOp(ctx, state, op, changed); err != nil { | 233 | if err := p.applyOp(ctx, state, op, changed); err != nil { | ||
| 232 | - | return true, err | 234 | + | return true, false, err |
| 233 | } | 235 | } | ||
| 234 | p.armEscape() | 236 | p.armEscape() | ||
| 235 | - | return true, nil | 237 | + | return true, false, nil |
| 236 | } | 238 | } | ||
| 237 | - | if p.overlay != nil && p.overlay.divertInput(input.Data) { | 239 | + | if p.overlay != nil && p.overlay.divertInput(input.Data, input.actionID) { |
| 238 | - | return true, nil | 240 | + | return true, true, nil |
| 239 | } | 241 | } | ||
| 240 | - | return false, nil | 242 | + | return false, false, nil |
| 241 | } | 243 | } | ||
| 242 | 244 | ||||
| 243 | // applyOp carries out one decoded decision: close sends the typed close and | 245 | // applyOp carries out one decoded decision: close sends the typed close and | ||
M internal/usecase/client/picker_overlay_test.go +130 -0
| @@ -207,6 +207,136 @@ func TestPickerOverlayCommitElsewhereSwapsAttachment(t *testing.T) { | |||
| 207 | 207 | } | |
| 208 | 208 | } | |
| 209 | 209 | ||
| 210 | + | // TestPickerOverlaySettlesUIActions pins UI-driver settlement across the | |
| 211 | + | // client picker overlay: the action that opened it settles on the daemon | |
| 212 | + | // receipt fenced on output the overlay applied without writing, an action the | |
| 213 | + | // picker consumes settles at the supervisor boundary without a daemon fence, | |
| 214 | + | // and a picker commit elsewhere follows the swap and settles on the | |
| 215 | + | // destination's first committed publication. | |
| 216 | + | func TestPickerOverlaySettlesUIActions(t *testing.T) { | |
| 217 | + | alpha := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "alpha"} | |
| 218 | + | beta := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "beta"} | |
| 219 | + | type actionOutcome struct { | |
| 220 | + | result ports.UIActionResult | |
| 221 | + | err error | |
| 222 | + | } | |
| 223 | + | tests := []struct { | |
| 224 | + | name string | |
| 225 | + | // overlayFirst opens the overlay before the action is admitted. | |
| 226 | + | overlayFirst bool | |
| 227 | + | keys []string | |
| 228 | + | // commit scripts a picker commit elsewhere for the consumed read. | |
| 229 | + | commit bool | |
| 230 | + | wantTarget protocol.ExactSessionTarget | |
| 231 | + | wantFence bool | |
| 232 | + | }{ | |
| 233 | + | {name: "the opening action settles on its suppressed receipt", keys: []string{"Enter"}, wantTarget: alpha, wantFence: true}, | |
| 234 | + | {name: "a picker-consumed action settles without a daemon fence", overlayFirst: true, keys: []string{"j"}, wantTarget: alpha}, | |
| 235 | + | {name: "a picker commit elsewhere settles on the destination", overlayFirst: true, keys: []string{"Enter"}, commit: true, wantTarget: beta}, | |
| 236 | + | } | |
| 237 | + | for _, tt := range tests { | |
| 238 | + | t.Run(tt.name, func(t *testing.T) { | |
| 239 | + | picker := newAttachTestPicker() | |
| 240 | + | var ui *UI | |
| 241 | + | harness := startAttachHarnessConfig(t, picker, func(cfg *SupervisorConfig) { | |
| 242 | + | ui = NewUI(cfg.Terminal.(ports.UIState), cfg.Clock) | |
| 243 | + | cfg.UI = ui | |
| 244 | + | }) | |
| 245 | + | stream := attachLiveSession(t, harness, picker) | |
| 246 | + | attached := sessionTestOutput(2, "\x1b[Hattached") | |
| 247 | + | attached.Full, attached.Base, attached.New = false, 1, 2 | |
| 248 | + | stream.deliver(attached) | |
| 249 | + | var snapshot ports.UISnapshot | |
| 250 | + | require.Eventually(t, func() bool { | |
| 251 | + | var err error | |
| 252 | + | snapshot, err = ui.Capture(ui.Handle()) | |
| 253 | + | return err == nil && snapshot.Context.Status == ports.UIStatusAttached && snapshot.Context.ViewPublication == 2 | |
| 254 | + | }, 5*time.Second, time.Millisecond) | |
| 255 | + | if tt.overlayFirst { | |
| 256 | + | stream.deliver(navigationOffer(1)) | |
| 257 | + | awaitPresentation(t, harness.sup, PresentAttachedPicker) | |
| 258 | + | // The composition paints the overlay with the live attachment's | |
| 259 | + | // context, so the attachment stays the actionable generation. | |
| 260 | + | overlayContext, ok := ui.OverlayContext() | |
| 261 | + | require.True(t, ok) | |
| 262 | + | require.Equal(t, ports.UIStatusAttached, overlayContext.Status) | |
| 263 | + | require.Equal(t, snapshot.Context.Generation, overlayContext.Generation) | |
| 264 | + | require.Equal(t, alpha, overlayContext.Route.Target) | |
| 265 | + | } | |
| 266 | + | var mu sync.Mutex | |
| 267 | + | second := newSessionTestStream() | |
| 268 | + | if tt.commit { | |
| 269 | + | harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |
| 270 | + | return second, nil | |
| 271 | + | }) | |
| 272 | + | other := sessionTestRequest(true) | |
| 273 | + | other.Target = beta | |
| 274 | + | picker.decideOnConsume(pickerOp{commit: true}, other) | |
| 275 | + | } | |
| 276 | + | fences := countSent[protocol.UIFence](stream) | |
| 277 | + | consumed := picker.consumedCount() | |
| 278 | + | ||
| 279 | + | done := make(chan actionOutcome, 1) | |
| 280 | + | go func() { | |
| 281 | + | result, err := ui.Action(t.Context(), ports.UIActionRequest{Attachment: ui.Handle(), Generation: snapshot.Context.Generation, Keys: tt.keys}) | |
| 282 | + | done <- actionOutcome{result: result, err: err} | |
| 283 | + | }() | |
| 284 | + | ||
| 285 | + | if !tt.overlayFirst { | |
| 286 | + | // The Enter reaches the daemon, whose command offers the picker; | |
| 287 | + | // the palette-close output then arrives under the overlay. | |
| 288 | + | fence := awaitSent(t, stream, "UIFence", isSent[protocol.UIFence]).(protocol.UIFence) | |
| 289 | + | stream.deliver(navigationOffer(1)) | |
| 290 | + | awaitPresentation(t, harness.sup, PresentAttachedPicker) | |
| 291 | + | hidden := sessionTestOutput(3, "\x1b[Hhidden") | |
| 292 | + | hidden.Full, hidden.Base, hidden.New = false, 2, 3 | |
| 293 | + | stream.deliver(hidden) | |
| 294 | + | require.Eventually(t, func() bool { | |
| 295 | + | ui.mu.Lock() | |
| 296 | + | defer ui.mu.Unlock() | |
| 297 | + | return ui.suppressed.ViewPublication == 3 | |
| 298 | + | }, 5*time.Second, time.Millisecond, "the suppressed output was never applied") | |
| 299 | + | stream.deliver(protocol.UIReceipt{ActionID: fence.ActionID, Epoch: 1, State: 3, ViewPublication: 3, Outcome: protocol.UIReceiptProcessed}) | |
| 300 | + | } | |
| 301 | + | if tt.commit { | |
| 302 | + | awaitSent(t, stream, "Detach", isSent[protocol.Detach]) | |
| 303 | + | awaitStreamHello(t, &mu, &second) | |
| 304 | + | second.deliver(protocol.Welcome{SessionName: "beta"}) | |
| 305 | + | output := sessionTestOutput(1, "\x1b[Hbeta") | |
| 306 | + | output.Context.Route.Target = beta | |
| 307 | + | second.deliver(output) | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | var outcome actionOutcome | |
| 311 | + | select { | |
| 312 | + | case outcome = <-done: | |
| 313 | + | case <-time.After(5 * time.Second): | |
| 314 | + | t.Fatal("the UI action never settled across the picker overlay") | |
| 315 | + | } | |
| 316 | + | require.NoError(t, outcome.err) | |
| 317 | + | require.Equal(t, ports.UIActionProcessed, outcome.result.Status) | |
| 318 | + | require.Equal(t, ports.UIStatusAttached, outcome.result.Context.Status) | |
| 319 | + | require.Equal(t, tt.wantTarget, outcome.result.Context.Route.Target) | |
| 320 | + | require.NotContains(t, harness.terminal.written(), "hidden", "suppressed output is never written over the picker") | |
| 321 | + | if tt.overlayFirst { | |
| 322 | + | require.Equal(t, consumed+1, picker.consumedCount(), "the picker consumed the action's input") | |
| 323 | + | } | |
| 324 | + | if !tt.wantFence { | |
| 325 | + | require.Equal(t, fences, countSent[protocol.UIFence](stream), "a picker-consumed action never fences through the daemon") | |
| 326 | + | require.Zero(t, countSent[protocol.Input](stream), "picker input never reaches the session") | |
| 327 | + | } | |
| 328 | + | if tt.commit { | |
| 329 | + | require.Greater(t, outcome.result.Context.Generation, snapshot.Context.Generation, "the swap commits a new generation") | |
| 330 | + | opened := harness.service.openedRequests() | |
| 331 | + | require.Len(t, opened, 2) | |
| 332 | + | require.Equal(t, beta, opened[1].Target) | |
| 333 | + | require.Greater(t, opened[1].Stream, picker.lastResolvedStream(), | |
| 334 | + | "the swap opens a stream identity allocated at open time, not the one reserved at commit") | |
| 335 | + | } | |
| 336 | + | }) | |
| 337 | + | } | |
| 338 | + | } | |
| 339 | + | ||
| 210 | 340 | // TestPickerCloseWithoutAttachment pins the unattached decision: with nothing | |
| 211 | 341 | // to return to, Escape and q keep the picker (and the process) alive; only the | |
| 212 | 342 | // explicit Ctrl+C exit ends the run. | |
| @@ -207,6 +207,136 @@ func TestPickerOverlayCommitElsewhereSwapsAttachment(t *testing.T) { | |||||
| 207 | } | 207 | } | ||
| 208 | } | 208 | } | ||
| 209 | 209 | ||||
| 210 | + | // TestPickerOverlaySettlesUIActions pins UI-driver settlement across the | |||
| 211 | + | // client picker overlay: the action that opened it settles on the daemon | |||
| 212 | + | // receipt fenced on output the overlay applied without writing, an action the | |||
| 213 | + | // picker consumes settles at the supervisor boundary without a daemon fence, | |||
| 214 | + | // and a picker commit elsewhere follows the swap and settles on the | |||
| 215 | + | // destination's first committed publication. | |||
| 216 | + | func TestPickerOverlaySettlesUIActions(t *testing.T) { | |||
| 217 | + | alpha := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "alpha"} | |||
| 218 | + | beta := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "beta"} | |||
| 219 | + | type actionOutcome struct { | |||
| 220 | + | result ports.UIActionResult | |||
| 221 | + | err error | |||
| 222 | + | } | |||
| 223 | + | tests := []struct { | |||
| 224 | + | name string | |||
| 225 | + | // overlayFirst opens the overlay before the action is admitted. | |||
| 226 | + | overlayFirst bool | |||
| 227 | + | keys []string | |||
| 228 | + | // commit scripts a picker commit elsewhere for the consumed read. | |||
| 229 | + | commit bool | |||
| 230 | + | wantTarget protocol.ExactSessionTarget | |||
| 231 | + | wantFence bool | |||
| 232 | + | }{ | |||
| 233 | + | {name: "the opening action settles on its suppressed receipt", keys: []string{"Enter"}, wantTarget: alpha, wantFence: true}, | |||
| 234 | + | {name: "a picker-consumed action settles without a daemon fence", overlayFirst: true, keys: []string{"j"}, wantTarget: alpha}, | |||
| 235 | + | {name: "a picker commit elsewhere settles on the destination", overlayFirst: true, keys: []string{"Enter"}, commit: true, wantTarget: beta}, | |||
| 236 | + | } | |||
| 237 | + | for _, tt := range tests { | |||
| 238 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 239 | + | picker := newAttachTestPicker() | |||
| 240 | + | var ui *UI | |||
| 241 | + | harness := startAttachHarnessConfig(t, picker, func(cfg *SupervisorConfig) { | |||
| 242 | + | ui = NewUI(cfg.Terminal.(ports.UIState), cfg.Clock) | |||
| 243 | + | cfg.UI = ui | |||
| 244 | + | }) | |||
| 245 | + | stream := attachLiveSession(t, harness, picker) | |||
| 246 | + | attached := sessionTestOutput(2, "\x1b[Hattached") | |||
| 247 | + | attached.Full, attached.Base, attached.New = false, 1, 2 | |||
| 248 | + | stream.deliver(attached) | |||
| 249 | + | var snapshot ports.UISnapshot | |||
| 250 | + | require.Eventually(t, func() bool { | |||
| 251 | + | var err error | |||
| 252 | + | snapshot, err = ui.Capture(ui.Handle()) | |||
| 253 | + | return err == nil && snapshot.Context.Status == ports.UIStatusAttached && snapshot.Context.ViewPublication == 2 | |||
| 254 | + | }, 5*time.Second, time.Millisecond) | |||
| 255 | + | if tt.overlayFirst { | |||
| 256 | + | stream.deliver(navigationOffer(1)) | |||
| 257 | + | awaitPresentation(t, harness.sup, PresentAttachedPicker) | |||
| 258 | + | // The composition paints the overlay with the live attachment's | |||
| 259 | + | // context, so the attachment stays the actionable generation. | |||
| 260 | + | overlayContext, ok := ui.OverlayContext() | |||
| 261 | + | require.True(t, ok) | |||
| 262 | + | require.Equal(t, ports.UIStatusAttached, overlayContext.Status) | |||
| 263 | + | require.Equal(t, snapshot.Context.Generation, overlayContext.Generation) | |||
| 264 | + | require.Equal(t, alpha, overlayContext.Route.Target) | |||
| 265 | + | } | |||
| 266 | + | var mu sync.Mutex | |||
| 267 | + | second := newSessionTestStream() | |||
| 268 | + | if tt.commit { | |||
| 269 | + | harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |||
| 270 | + | return second, nil | |||
| 271 | + | }) | |||
| 272 | + | other := sessionTestRequest(true) | |||
| 273 | + | other.Target = beta | |||
| 274 | + | picker.decideOnConsume(pickerOp{commit: true}, other) | |||
| 275 | + | } | |||
| 276 | + | fences := countSent[protocol.UIFence](stream) | |||
| 277 | + | consumed := picker.consumedCount() | |||
| 278 | + | ||||
| 279 | + | done := make(chan actionOutcome, 1) | |||
| 280 | + | go func() { | |||
| 281 | + | result, err := ui.Action(t.Context(), ports.UIActionRequest{Attachment: ui.Handle(), Generation: snapshot.Context.Generation, Keys: tt.keys}) | |||
| 282 | + | done <- actionOutcome{result: result, err: err} | |||
| 283 | + | }() | |||
| 284 | + | ||||
| 285 | + | if !tt.overlayFirst { | |||
| 286 | + | // The Enter reaches the daemon, whose command offers the picker; | |||
| 287 | + | // the palette-close output then arrives under the overlay. | |||
| 288 | + | fence := awaitSent(t, stream, "UIFence", isSent[protocol.UIFence]).(protocol.UIFence) | |||
| 289 | + | stream.deliver(navigationOffer(1)) | |||
| 290 | + | awaitPresentation(t, harness.sup, PresentAttachedPicker) | |||
| 291 | + | hidden := sessionTestOutput(3, "\x1b[Hhidden") | |||
| 292 | + | hidden.Full, hidden.Base, hidden.New = false, 2, 3 | |||
| 293 | + | stream.deliver(hidden) | |||
| 294 | + | require.Eventually(t, func() bool { | |||
| 295 | + | ui.mu.Lock() | |||
| 296 | + | defer ui.mu.Unlock() | |||
| 297 | + | return ui.suppressed.ViewPublication == 3 | |||
| 298 | + | }, 5*time.Second, time.Millisecond, "the suppressed output was never applied") | |||
| 299 | + | stream.deliver(protocol.UIReceipt{ActionID: fence.ActionID, Epoch: 1, State: 3, ViewPublication: 3, Outcome: protocol.UIReceiptProcessed}) | |||
| 300 | + | } | |||
| 301 | + | if tt.commit { | |||
| 302 | + | awaitSent(t, stream, "Detach", isSent[protocol.Detach]) | |||
| 303 | + | awaitStreamHello(t, &mu, &second) | |||
| 304 | + | second.deliver(protocol.Welcome{SessionName: "beta"}) | |||
| 305 | + | output := sessionTestOutput(1, "\x1b[Hbeta") | |||
| 306 | + | output.Context.Route.Target = beta | |||
| 307 | + | second.deliver(output) | |||
| 308 | + | } | |||
| 309 | + | ||||
| 310 | + | var outcome actionOutcome | |||
| 311 | + | select { | |||
| 312 | + | case outcome = <-done: | |||
| 313 | + | case <-time.After(5 * time.Second): | |||
| 314 | + | t.Fatal("the UI action never settled across the picker overlay") | |||
| 315 | + | } | |||
| 316 | + | require.NoError(t, outcome.err) | |||
| 317 | + | require.Equal(t, ports.UIActionProcessed, outcome.result.Status) | |||
| 318 | + | require.Equal(t, ports.UIStatusAttached, outcome.result.Context.Status) | |||
| 319 | + | require.Equal(t, tt.wantTarget, outcome.result.Context.Route.Target) | |||
| 320 | + | require.NotContains(t, harness.terminal.written(), "hidden", "suppressed output is never written over the picker") | |||
| 321 | + | if tt.overlayFirst { | |||
| 322 | + | require.Equal(t, consumed+1, picker.consumedCount(), "the picker consumed the action's input") | |||
| 323 | + | } | |||
| 324 | + | if !tt.wantFence { | |||
| 325 | + | require.Equal(t, fences, countSent[protocol.UIFence](stream), "a picker-consumed action never fences through the daemon") | |||
| 326 | + | require.Zero(t, countSent[protocol.Input](stream), "picker input never reaches the session") | |||
| 327 | + | } | |||
| 328 | + | if tt.commit { | |||
| 329 | + | require.Greater(t, outcome.result.Context.Generation, snapshot.Context.Generation, "the swap commits a new generation") | |||
| 330 | + | opened := harness.service.openedRequests() | |||
| 331 | + | require.Len(t, opened, 2) | |||
| 332 | + | require.Equal(t, beta, opened[1].Target) | |||
| 333 | + | require.Greater(t, opened[1].Stream, picker.lastResolvedStream(), | |||
| 334 | + | "the swap opens a stream identity allocated at open time, not the one reserved at commit") | |||
| 335 | + | } | |||
| 336 | + | }) | |||
| 337 | + | } | |||
| 338 | + | } | |||
| 339 | + | ||||
| 210 | // TestPickerCloseWithoutAttachment pins the unattached decision: with nothing | 340 | // TestPickerCloseWithoutAttachment pins the unattached decision: with nothing | ||
| 211 | // to return to, Escape and q keep the picker (and the process) alive; only the | 341 | // to return to, Escape and q keep the picker (and the process) alive; only the | ||
| 212 | // explicit Ctrl+C exit ends the run. | 342 | // explicit Ctrl+C exit ends the run. | ||
M internal/usecase/client/supervisor_attach.go +12 -1
| @@ -509,7 +509,16 @@ func (s *Supervisor) runResolvedAttachment(ctx context.Context, input *terminalI | |||
| 509 | 509 | } | |
| 510 | 510 | // A commit from the picker overlay to another target: the previous | |
| 511 | 511 | // attachment detached cleanly, and the swap goes through Connecting | |
| 512 | - | // exactly like any committed selection. | |
| 512 | + | // exactly like any committed selection. The stream identity is | |
| 513 | + | // allocated now, not at commit: streams opened while the source was | |
| 514 | + | // live (route publications) may have moved the connection's | |
| 515 | + | // anti-replay window past an identity reserved earlier. | |
| 516 | + | stream, err := service.NextStreamID() | |
| 517 | + | if err != nil { | |
| 518 | + | s.reportAttachmentFailure(err) | |
| 519 | + | return false, nil | |
| 520 | + | } | |
| 521 | + | swap.request.Stream = stream | |
| 513 | 522 | target, localProvenance = *swap, SessionEnvironmentLocalPicker | |
| 514 | 523 | } | |
| 515 | 524 | } | |
| @@ -633,6 +642,8 @@ settlement: | |||
| 633 | 642 | overlay.publishPreview() | |
| 634 | 643 | case <-overlay.ops(): | |
| 635 | 644 | overlay.takeOp() | |
| 645 | + | case consumed := <-s.attachments.OverlayActions(): | |
| 646 | + | overlay.settleAction(consumed) | |
| 636 | 647 | case <-overlay.invalidation(): | |
| 637 | 648 | overlay.resize() | |
| 638 | 649 | case outcome := <-s.kills.results(): | |
| @@ -509,7 +509,16 @@ func (s *Supervisor) runResolvedAttachment(ctx context.Context, input *terminalI | |||||
| 509 | } | 509 | } | ||
| 510 | // A commit from the picker overlay to another target: the previous | 510 | // A commit from the picker overlay to another target: the previous | ||
| 511 | // attachment detached cleanly, and the swap goes through Connecting | 511 | // attachment detached cleanly, and the swap goes through Connecting | ||
| 512 | - | // exactly like any committed selection. | 512 | + | // exactly like any committed selection. The stream identity is |
| 513 | + | // allocated now, not at commit: streams opened while the source was | |||
| 514 | + | // live (route publications) may have moved the connection's | |||
| 515 | + | // anti-replay window past an identity reserved earlier. | |||
| 516 | + | stream, err := service.NextStreamID() | |||
| 517 | + | if err != nil { | |||
| 518 | + | s.reportAttachmentFailure(err) | |||
| 519 | + | return false, nil | |||
| 520 | + | } | |||
| 521 | + | swap.request.Stream = stream | |||
| 513 | target, localProvenance = *swap, SessionEnvironmentLocalPicker | 522 | target, localProvenance = *swap, SessionEnvironmentLocalPicker | ||
| 514 | } | 523 | } | ||
| 515 | } | 524 | } | ||
| @@ -633,6 +642,8 @@ settlement: | |||||
| 633 | overlay.publishPreview() | 642 | overlay.publishPreview() | ||
| 634 | case <-overlay.ops(): | 643 | case <-overlay.ops(): | ||
| 635 | overlay.takeOp() | 644 | overlay.takeOp() | ||
| 645 | + | case consumed := <-s.attachments.OverlayActions(): | |||
| 646 | + | overlay.settleAction(consumed) | |||
| 636 | case <-overlay.invalidation(): | 647 | case <-overlay.invalidation(): | ||
| 637 | overlay.resize() | 648 | overlay.resize() | ||
| 638 | case outcome := <-s.kills.results(): | 649 | case outcome := <-s.kills.results(): | ||
M internal/usecase/client/supervisor_attach_test.go +28 -0
| @@ -268,6 +268,12 @@ type attachTestPicker struct { | |||
| 268 | 268 | resolveErr error | |
| 269 | 269 | request ports.BrokerOpenStreamRequest | |
| 270 | 270 | resolved int | |
| 271 | + | // consumeOp, when set, is the decision the next consumed read records, | |
| 272 | + | // exactly like the controller deciding a commit from an Enter keypress. | |
| 273 | + | consumeOp pickerOp | |
| 274 | + | consumeRequest ports.BrokerOpenStreamRequest | |
| 275 | + | // resolvedStream is the stream identity the last resolution reserved. | |
| 276 | + | resolvedStream ports.BrokerStreamID | |
| 271 | 277 | } | |
| 272 | 278 | ||
| 273 | 279 | func newAttachTestPicker() *attachTestPicker { | |
| @@ -283,9 +289,24 @@ func (p *attachTestPicker) ConsumeTerminalRead([]byte) bool { | |||
| 283 | 289 | return false | |
| 284 | 290 | } | |
| 285 | 291 | p.consumed++ | |
| 292 | + | if p.consumeOp != (pickerOp{}) { | |
| 293 | + | p.op, p.key, p.request = p.consumeOp, "row", p.consumeRequest | |
| 294 | + | p.consumeOp = pickerOp{} | |
| 295 | + | select { | |
| 296 | + | case p.opsReady <- struct{}{}: | |
| 297 | + | default: | |
| 298 | + | } | |
| 299 | + | } | |
| 286 | 300 | return true | |
| 287 | 301 | } | |
| 288 | 302 | ||
| 303 | + | // decideOnConsume scripts the decision the next consumed read records. | |
| 304 | + | func (p *attachTestPicker) decideOnConsume(op pickerOp, request ports.BrokerOpenStreamRequest) { | |
| 305 | + | p.mu.Lock() | |
| 306 | + | defer p.mu.Unlock() | |
| 307 | + | p.consumeOp, p.consumeRequest = op, request | |
| 308 | + | } | |
| 309 | + | ||
| 289 | 310 | func (p *attachTestPicker) TakeOp() (pickerOp, string) { | |
| 290 | 311 | p.mu.Lock() | |
| 291 | 312 | defer p.mu.Unlock() | |
| @@ -304,6 +325,7 @@ func (p *attachTestPicker) ResolveKey(key string, base pickerResolveBase) (ports | |||
| 304 | 325 | request := p.request | |
| 305 | 326 | request.Connection = base.Connection | |
| 306 | 327 | request.Stream = base.Stream | |
| 328 | + | p.resolvedStream = base.Stream | |
| 307 | 329 | return request, nil | |
| 308 | 330 | } | |
| 309 | 331 | ||
| @@ -333,6 +355,12 @@ func (p *attachTestPicker) owns() bool { | |||
| 333 | 355 | return p.ownsInput | |
| 334 | 356 | } | |
| 335 | 357 | ||
| 358 | + | func (p *attachTestPicker) lastResolvedStream() ports.BrokerStreamID { | |
| 359 | + | p.mu.Lock() | |
| 360 | + | defer p.mu.Unlock() | |
| 361 | + | return p.resolvedStream | |
| 362 | + | } | |
| 363 | + | ||
| 336 | 364 | func (p *attachTestPicker) consumedCount() int { | |
| 337 | 365 | p.mu.Lock() | |
| 338 | 366 | defer p.mu.Unlock() | |
| @@ -268,6 +268,12 @@ type attachTestPicker struct { | |||||
| 268 | resolveErr error | 268 | resolveErr error | ||
| 269 | request ports.BrokerOpenStreamRequest | 269 | request ports.BrokerOpenStreamRequest | ||
| 270 | resolved int | 270 | resolved int | ||
| 271 | + | // consumeOp, when set, is the decision the next consumed read records, | |||
| 272 | + | // exactly like the controller deciding a commit from an Enter keypress. | |||
| 273 | + | consumeOp pickerOp | |||
| 274 | + | consumeRequest ports.BrokerOpenStreamRequest | |||
| 275 | + | // resolvedStream is the stream identity the last resolution reserved. | |||
| 276 | + | resolvedStream ports.BrokerStreamID | |||
| 271 | } | 277 | } | ||
| 272 | 278 | ||||
| 273 | func newAttachTestPicker() *attachTestPicker { | 279 | func newAttachTestPicker() *attachTestPicker { | ||
| @@ -283,9 +289,24 @@ func (p *attachTestPicker) ConsumeTerminalRead([]byte) bool { | |||||
| 283 | return false | 289 | return false | ||
| 284 | } | 290 | } | ||
| 285 | p.consumed++ | 291 | p.consumed++ | ||
| 292 | + | if p.consumeOp != (pickerOp{}) { | |||
| 293 | + | p.op, p.key, p.request = p.consumeOp, "row", p.consumeRequest | |||
| 294 | + | p.consumeOp = pickerOp{} | |||
| 295 | + | select { | |||
| 296 | + | case p.opsReady <- struct{}{}: | |||
| 297 | + | default: | |||
| 298 | + | } | |||
| 299 | + | } | |||
| 286 | return true | 300 | return true | ||
| 287 | } | 301 | } | ||
| 288 | 302 | ||||
| 303 | + | // decideOnConsume scripts the decision the next consumed read records. | |||
| 304 | + | func (p *attachTestPicker) decideOnConsume(op pickerOp, request ports.BrokerOpenStreamRequest) { | |||
| 305 | + | p.mu.Lock() | |||
| 306 | + | defer p.mu.Unlock() | |||
| 307 | + | p.consumeOp, p.consumeRequest = op, request | |||
| 308 | + | } | |||
| 309 | + | ||||
| 289 | func (p *attachTestPicker) TakeOp() (pickerOp, string) { | 310 | func (p *attachTestPicker) TakeOp() (pickerOp, string) { | ||
| 290 | p.mu.Lock() | 311 | p.mu.Lock() | ||
| 291 | defer p.mu.Unlock() | 312 | defer p.mu.Unlock() | ||
| @@ -304,6 +325,7 @@ func (p *attachTestPicker) ResolveKey(key string, base pickerResolveBase) (ports | |||||
| 304 | request := p.request | 325 | request := p.request | ||
| 305 | request.Connection = base.Connection | 326 | request.Connection = base.Connection | ||
| 306 | request.Stream = base.Stream | 327 | request.Stream = base.Stream | ||
| 328 | + | p.resolvedStream = base.Stream | |||
| 307 | return request, nil | 329 | return request, nil | ||
| 308 | } | 330 | } | ||
| 309 | 331 | ||||
| @@ -333,6 +355,12 @@ func (p *attachTestPicker) owns() bool { | |||||
| 333 | return p.ownsInput | 355 | return p.ownsInput | ||
| 334 | } | 356 | } | ||
| 335 | 357 | ||||
| 358 | + | func (p *attachTestPicker) lastResolvedStream() ports.BrokerStreamID { | |||
| 359 | + | p.mu.Lock() | |||
| 360 | + | defer p.mu.Unlock() | |||
| 361 | + | return p.resolvedStream | |||
| 362 | + | } | |||
| 363 | + | ||||
| 336 | func (p *attachTestPicker) consumedCount() int { | 364 | func (p *attachTestPicker) consumedCount() int { | ||
| 337 | p.mu.Lock() | 365 | p.mu.Lock() | ||
| 338 | defer p.mu.Unlock() | 366 | defer p.mu.Unlock() | ||
M internal/usecase/client/supervisor_overlay.go +19 -0
| @@ -155,6 +155,11 @@ func (o *attachmentPickerOverlay) takeOp() { | |||
| 155 | 155 | o.exit() | |
| 156 | 156 | return | |
| 157 | 157 | } | |
| 158 | + | // The driver action that committed follows the swap and settles on | |
| 159 | + | // the destination's first committed publication. | |
| 160 | + | if s.cfg.UI != nil && o.run != nil && o.run.fg != nil { | |
| 161 | + | s.cfg.UI.followOverlay(o.run.fg.uiGeneration) | |
| 162 | + | } | |
| 158 | 163 | s.pendingSwap = &pickerAttachmentTarget{request: request, tab: tab} | |
| 159 | 164 | o.swapping = true | |
| 160 | 165 | s.preview.close(o.picker()) | |
| @@ -168,6 +173,20 @@ func (o *attachmentPickerOverlay) takeOp() { | |||
| 168 | 173 | } | |
| 169 | 174 | } | |
| 170 | 175 | ||
| 176 | + | // settleAction completes one driver action the overlay consumed. The read's | |
| 177 | + | // decision was recorded before the worker handed the action over, so any | |
| 178 | + | // pending decision is carried out first: a commit elsewhere follows the action | |
| 179 | + | // across the swap, and everything else settles it at this boundary after the | |
| 180 | + | // picker repainted. | |
| 181 | + | func (o *attachmentPickerOverlay) settleAction(consumed overlayActionConsumed) { | |
| 182 | + | if o.active && !o.swapping { | |
| 183 | + | o.takeOp() | |
| 184 | + | } | |
| 185 | + | if o.sup.cfg.UI != nil { | |
| 186 | + | o.sup.cfg.UI.settleOverlay(consumed.generation, consumed.actionID) | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | ||
| 171 | 190 | // current names the attachment this overlay is presented over, so the picker | |
| 172 | 191 | // opens on its session and tab. | |
| 173 | 192 | func (o *attachmentPickerOverlay) current() pickerCurrent { | |
| @@ -155,6 +155,11 @@ func (o *attachmentPickerOverlay) takeOp() { | |||||
| 155 | o.exit() | 155 | o.exit() | ||
| 156 | return | 156 | return | ||
| 157 | } | 157 | } | ||
| 158 | + | // The driver action that committed follows the swap and settles on | |||
| 159 | + | // the destination's first committed publication. | |||
| 160 | + | if s.cfg.UI != nil && o.run != nil && o.run.fg != nil { | |||
| 161 | + | s.cfg.UI.followOverlay(o.run.fg.uiGeneration) | |||
| 162 | + | } | |||
| 158 | s.pendingSwap = &pickerAttachmentTarget{request: request, tab: tab} | 163 | s.pendingSwap = &pickerAttachmentTarget{request: request, tab: tab} | ||
| 159 | o.swapping = true | 164 | o.swapping = true | ||
| 160 | s.preview.close(o.picker()) | 165 | s.preview.close(o.picker()) | ||
| @@ -168,6 +173,20 @@ func (o *attachmentPickerOverlay) takeOp() { | |||||
| 168 | } | 173 | } | ||
| 169 | } | 174 | } | ||
| 170 | 175 | ||||
| 176 | + | // settleAction completes one driver action the overlay consumed. The read's | |||
| 177 | + | // decision was recorded before the worker handed the action over, so any | |||
| 178 | + | // pending decision is carried out first: a commit elsewhere follows the action | |||
| 179 | + | // across the swap, and everything else settles it at this boundary after the | |||
| 180 | + | // picker repainted. | |||
| 181 | + | func (o *attachmentPickerOverlay) settleAction(consumed overlayActionConsumed) { | |||
| 182 | + | if o.active && !o.swapping { | |||
| 183 | + | o.takeOp() | |||
| 184 | + | } | |||
| 185 | + | if o.sup.cfg.UI != nil { | |||
| 186 | + | o.sup.cfg.UI.settleOverlay(consumed.generation, consumed.actionID) | |||
| 187 | + | } | |||
| 188 | + | } | |||
| 189 | + | ||||
| 171 | // current names the attachment this overlay is presented over, so the picker | 190 | // current names the attachment this overlay is presented over, so the picker | ||
| 172 | // opens on its session and tab. | 191 | // opens on its session and tab. | ||
| 173 | func (o *attachmentPickerOverlay) current() pickerCurrent { | 192 | func (o *attachmentPickerOverlay) current() pickerCurrent { | ||
M internal/usecase/client/ui.go +6 -0
| @@ -38,6 +38,12 @@ type UI struct { | |||
| 38 | 38 | dispatched map[uint64]bool | |
| 39 | 39 | completion map[uint64]chan struct{} | |
| 40 | 40 | handoff *uiActionHandoff | |
| 41 | + | // suppressed is the newest attached context the foreground applied but | |
| 42 | + | // did not write because the client picker overlay owned the terminal. | |
| 43 | + | suppressed ports.UIContext | |
| 44 | + | // overlayAction is the admitted action whose input the client picker | |
| 45 | + | // overlay consumed; the supervisor settles it at its event-loop boundary. | |
| 46 | + | overlayAction uint64 | |
| 41 | 47 | } | |
| 42 | 48 | ||
| 43 | 49 | type uiActionHandoff struct { | |
| @@ -38,6 +38,12 @@ type UI struct { | |||||
| 38 | dispatched map[uint64]bool | 38 | dispatched map[uint64]bool | ||
| 39 | completion map[uint64]chan struct{} | 39 | completion map[uint64]chan struct{} | ||
| 40 | handoff *uiActionHandoff | 40 | handoff *uiActionHandoff | ||
| 41 | + | // suppressed is the newest attached context the foreground applied but | |||
| 42 | + | // did not write because the client picker overlay owned the terminal. | |||
| 43 | + | suppressed ports.UIContext | |||
| 44 | + | // overlayAction is the admitted action whose input the client picker | |||
| 45 | + | // overlay consumed; the supervisor settles it at its event-loop boundary. | |||
| 46 | + | overlayAction uint64 | |||
| 41 | } | 47 | } | ||
| 42 | 48 | ||||
| 43 | type uiActionHandoff struct { | 49 | type uiActionHandoff struct { | ||
M internal/usecase/client/ui_actions.go +97 -2
| @@ -17,6 +17,7 @@ func (u *UI) bindForeground(ctx context.Context, input *terminalInputPump, consu | |||
| 17 | 17 | u.consumer = consumer | |
| 18 | 18 | u.foreground = ctx | |
| 19 | 19 | u.boundary = ports.UIActionResult{} | |
| 20 | + | u.suppressed = ports.UIContext{} | |
| 20 | 21 | if u.handoff != nil && u.handoff.destinationGeneration == 0 { | |
| 21 | 22 | u.handoff.destinationGeneration = u.generation | |
| 22 | 23 | } else if u.pending != 0 { | |
| @@ -138,6 +139,9 @@ func (u *UI) finishLocked(id uint64, status ports.UIActionStatus, boundary ports | |||
| 138 | 139 | if u.pending == id { | |
| 139 | 140 | u.pending = 0 | |
| 140 | 141 | } | |
| 142 | + | if u.overlayAction == id { | |
| 143 | + | u.overlayAction = 0 | |
| 144 | + | } | |
| 141 | 145 | u.signalLocked() | |
| 142 | 146 | } | |
| 143 | 147 | ||
| @@ -154,6 +158,7 @@ func (u *UI) published(generation uint64) { | |||
| 154 | 158 | return | |
| 155 | 159 | } | |
| 156 | 160 | u.boundary = ports.UIActionResult{Revision: snapshot.Revision, Context: snapshot.Context} | |
| 161 | + | u.suppressed = ports.UIContext{} | |
| 157 | 162 | if u.handoff != nil && u.handoff.destinationGeneration == generation && snapshot.Context.Status == ports.UIStatusAttached { | |
| 158 | 163 | u.handoff.boundary = u.boundary | |
| 159 | 164 | if u.dispatched[u.handoff.actionID] { | |
| @@ -178,10 +183,100 @@ func (u *UI) receipt(generation uint64, receipt protocol.UIReceipt) { | |||
| 178 | 183 | return | |
| 179 | 184 | } | |
| 180 | 185 | boundary := u.boundary | |
| 181 | - | if boundary.Context.OutputEpoch != receipt.Epoch || boundary.Context.OutputState != receipt.State || boundary.Context.ViewPublication != receipt.ViewPublication || boundary.Revision == 0 { | |
| 186 | + | if boundary.Revision == 0 { | |
| 187 | + | return | |
| 188 | + | } | |
| 189 | + | if receiptMatches(boundary.Context, receipt) { | |
| 190 | + | u.finishLocked(receipt.ActionID, ports.UIActionProcessed, boundary) | |
| 191 | + | return | |
| 192 | + | } | |
| 193 | + | // The client picker overlay suppressed the output that reached this | |
| 194 | + | // boundary: it was applied, not written. Its committed context is the | |
| 195 | + | // boundary, and the last written revision keeps later waits eligible. | |
| 196 | + | if u.suppressed.Generation == generation && receiptMatches(u.suppressed, receipt) { | |
| 197 | + | u.finishLocked(receipt.ActionID, ports.UIActionProcessed, ports.UIActionResult{Revision: boundary.Revision, Context: u.suppressed}) | |
| 198 | + | } | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | func receiptMatches(context ports.UIContext, receipt protocol.UIReceipt) bool { | |
| 202 | + | return context.OutputEpoch == receipt.Epoch && context.OutputState == receipt.State && context.ViewPublication == receipt.ViewPublication | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | // suppressedOutput records the committed context of attachment output the | |
| 206 | + | // client picker overlay applied without writing, so a daemon receipt fenced | |
| 207 | + | // on it still settles its action. | |
| 208 | + | func (u *UI) suppressedOutput(generation uint64, context ports.UIContext) { | |
| 209 | + | u.mu.Lock() | |
| 210 | + | defer u.mu.Unlock() | |
| 211 | + | if generation == 0 || generation != u.generation || context.Generation != generation || context.Status != ports.UIStatusAttached { | |
| 212 | + | return | |
| 213 | + | } | |
| 214 | + | u.suppressed = context | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | // OverlayContext is the attached context a composition publishes while the | |
| 218 | + | // client picker is composed over the live attachment. The attachment is still | |
| 219 | + | // the actionable generation, so the picker frame keeps its session identity and | |
| 220 | + | // generation instead of an unattached Picker presentation. It reports false when | |
| 221 | + | // no attached boundary of the current foreground exists. | |
| 222 | + | func (u *UI) OverlayContext() (ports.UIContext, bool) { | |
| 223 | + | if u == nil { | |
| 224 | + | return ports.UIContext{}, false | |
| 225 | + | } | |
| 226 | + | u.mu.Lock() | |
| 227 | + | defer u.mu.Unlock() | |
| 228 | + | context := u.boundary.Context | |
| 229 | + | if u.suppressed.Generation != 0 { | |
| 230 | + | context = u.suppressed | |
| 231 | + | } | |
| 232 | + | if u.input == nil || context.Generation == 0 || context.Generation != u.generation || context.Status != ports.UIStatusAttached { | |
| 233 | + | return ports.UIContext{}, false | |
| 234 | + | } | |
| 235 | + | return context, true | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | // markOverlayInput records that the admitted action id is about to be consumed | |
| 239 | + | // by the client picker overlay. It runs before the picker sees the bytes, so a | |
| 240 | + | // commit the read decides can always find the action to follow. | |
| 241 | + | func (u *UI) markOverlayInput(generation, id uint64) { | |
| 242 | + | u.mu.Lock() | |
| 243 | + | defer u.mu.Unlock() | |
| 244 | + | if id == 0 || generation != u.generation || u.pending != id { | |
| 182 | 245 | return | |
| 183 | 246 | } | |
| 184 | - | u.finishLocked(receipt.ActionID, ports.UIActionProcessed, boundary) | |
| 247 | + | u.overlayAction = id | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | // followOverlay retains the overlay-consumed action across the attachment swap | |
| 251 | + | // a picker commit started, so it settles on the destination's first committed | |
| 252 | + | // publication. | |
| 253 | + | func (u *UI) followOverlay(generation uint64) { | |
| 254 | + | u.mu.Lock() | |
| 255 | + | id := u.overlayAction | |
| 256 | + | u.mu.Unlock() | |
| 257 | + | if id != 0 { | |
| 258 | + | u.follow(generation, id) | |
| 259 | + | } | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | // settleOverlay completes one overlay-consumed action at the supervisor's | |
| 263 | + | // event-loop boundary: the picker applied its input and repainted. A followed | |
| 264 | + | // action is left to the destination publication. | |
| 265 | + | func (u *UI) settleOverlay(generation, id uint64) { | |
| 266 | + | snapshot, err := u.state.Snapshot() | |
| 267 | + | u.mu.Lock() | |
| 268 | + | defer u.mu.Unlock() | |
| 269 | + | if id == 0 || u.overlayAction != id || u.pending != id || generation != u.generation { | |
| 270 | + | return | |
| 271 | + | } | |
| 272 | + | if u.handoff != nil && u.handoff.actionID == id { | |
| 273 | + | return | |
| 274 | + | } | |
| 275 | + | boundary := u.boundary | |
| 276 | + | if err == nil && snapshot.Context.Generation == generation && snapshot.Context.Status == ports.UIStatusAttached { | |
| 277 | + | boundary = ports.UIActionResult{Revision: snapshot.Revision, Context: snapshot.Context} | |
| 278 | + | } | |
| 279 | + | u.finishLocked(id, ports.UIActionProcessed, boundary) | |
| 185 | 280 | } | |
| 186 | 281 | ||
| 187 | 282 | func (u *UI) Action(ctx context.Context, request ports.UIActionRequest) (ports.UIActionResult, error) { | |
| @@ -17,6 +17,7 @@ func (u *UI) bindForeground(ctx context.Context, input *terminalInputPump, consu | |||||
| 17 | u.consumer = consumer | 17 | u.consumer = consumer | ||
| 18 | u.foreground = ctx | 18 | u.foreground = ctx | ||
| 19 | u.boundary = ports.UIActionResult{} | 19 | u.boundary = ports.UIActionResult{} | ||
| 20 | + | u.suppressed = ports.UIContext{} | |||
| 20 | if u.handoff != nil && u.handoff.destinationGeneration == 0 { | 21 | if u.handoff != nil && u.handoff.destinationGeneration == 0 { | ||
| 21 | u.handoff.destinationGeneration = u.generation | 22 | u.handoff.destinationGeneration = u.generation | ||
| 22 | } else if u.pending != 0 { | 23 | } else if u.pending != 0 { | ||
| @@ -138,6 +139,9 @@ func (u *UI) finishLocked(id uint64, status ports.UIActionStatus, boundary ports | |||||
| 138 | if u.pending == id { | 139 | if u.pending == id { | ||
| 139 | u.pending = 0 | 140 | u.pending = 0 | ||
| 140 | } | 141 | } | ||
| 142 | + | if u.overlayAction == id { | |||
| 143 | + | u.overlayAction = 0 | |||
| 144 | + | } | |||
| 141 | u.signalLocked() | 145 | u.signalLocked() | ||
| 142 | } | 146 | } | ||
| 143 | 147 | ||||
| @@ -154,6 +158,7 @@ func (u *UI) published(generation uint64) { | |||||
| 154 | return | 158 | return | ||
| 155 | } | 159 | } | ||
| 156 | u.boundary = ports.UIActionResult{Revision: snapshot.Revision, Context: snapshot.Context} | 160 | u.boundary = ports.UIActionResult{Revision: snapshot.Revision, Context: snapshot.Context} | ||
| 161 | + | u.suppressed = ports.UIContext{} | |||
| 157 | if u.handoff != nil && u.handoff.destinationGeneration == generation && snapshot.Context.Status == ports.UIStatusAttached { | 162 | if u.handoff != nil && u.handoff.destinationGeneration == generation && snapshot.Context.Status == ports.UIStatusAttached { | ||
| 158 | u.handoff.boundary = u.boundary | 163 | u.handoff.boundary = u.boundary | ||
| 159 | if u.dispatched[u.handoff.actionID] { | 164 | if u.dispatched[u.handoff.actionID] { | ||
| @@ -178,10 +183,100 @@ func (u *UI) receipt(generation uint64, receipt protocol.UIReceipt) { | |||||
| 178 | return | 183 | return | ||
| 179 | } | 184 | } | ||
| 180 | boundary := u.boundary | 185 | boundary := u.boundary | ||
| 181 | - | if boundary.Context.OutputEpoch != receipt.Epoch || boundary.Context.OutputState != receipt.State || boundary.Context.ViewPublication != receipt.ViewPublication || boundary.Revision == 0 { | 186 | + | if boundary.Revision == 0 { |
| 187 | + | return | |||
| 188 | + | } | |||
| 189 | + | if receiptMatches(boundary.Context, receipt) { | |||
| 190 | + | u.finishLocked(receipt.ActionID, ports.UIActionProcessed, boundary) | |||
| 191 | + | return | |||
| 192 | + | } | |||
| 193 | + | // The client picker overlay suppressed the output that reached this | |||
| 194 | + | // boundary: it was applied, not written. Its committed context is the | |||
| 195 | + | // boundary, and the last written revision keeps later waits eligible. | |||
| 196 | + | if u.suppressed.Generation == generation && receiptMatches(u.suppressed, receipt) { | |||
| 197 | + | u.finishLocked(receipt.ActionID, ports.UIActionProcessed, ports.UIActionResult{Revision: boundary.Revision, Context: u.suppressed}) | |||
| 198 | + | } | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | func receiptMatches(context ports.UIContext, receipt protocol.UIReceipt) bool { | |||
| 202 | + | return context.OutputEpoch == receipt.Epoch && context.OutputState == receipt.State && context.ViewPublication == receipt.ViewPublication | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | // suppressedOutput records the committed context of attachment output the | |||
| 206 | + | // client picker overlay applied without writing, so a daemon receipt fenced | |||
| 207 | + | // on it still settles its action. | |||
| 208 | + | func (u *UI) suppressedOutput(generation uint64, context ports.UIContext) { | |||
| 209 | + | u.mu.Lock() | |||
| 210 | + | defer u.mu.Unlock() | |||
| 211 | + | if generation == 0 || generation != u.generation || context.Generation != generation || context.Status != ports.UIStatusAttached { | |||
| 212 | + | return | |||
| 213 | + | } | |||
| 214 | + | u.suppressed = context | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | // OverlayContext is the attached context a composition publishes while the | |||
| 218 | + | // client picker is composed over the live attachment. The attachment is still | |||
| 219 | + | // the actionable generation, so the picker frame keeps its session identity and | |||
| 220 | + | // generation instead of an unattached Picker presentation. It reports false when | |||
| 221 | + | // no attached boundary of the current foreground exists. | |||
| 222 | + | func (u *UI) OverlayContext() (ports.UIContext, bool) { | |||
| 223 | + | if u == nil { | |||
| 224 | + | return ports.UIContext{}, false | |||
| 225 | + | } | |||
| 226 | + | u.mu.Lock() | |||
| 227 | + | defer u.mu.Unlock() | |||
| 228 | + | context := u.boundary.Context | |||
| 229 | + | if u.suppressed.Generation != 0 { | |||
| 230 | + | context = u.suppressed | |||
| 231 | + | } | |||
| 232 | + | if u.input == nil || context.Generation == 0 || context.Generation != u.generation || context.Status != ports.UIStatusAttached { | |||
| 233 | + | return ports.UIContext{}, false | |||
| 234 | + | } | |||
| 235 | + | return context, true | |||
| 236 | + | } | |||
| 237 | + | ||||
| 238 | + | // markOverlayInput records that the admitted action id is about to be consumed | |||
| 239 | + | // by the client picker overlay. It runs before the picker sees the bytes, so a | |||
| 240 | + | // commit the read decides can always find the action to follow. | |||
| 241 | + | func (u *UI) markOverlayInput(generation, id uint64) { | |||
| 242 | + | u.mu.Lock() | |||
| 243 | + | defer u.mu.Unlock() | |||
| 244 | + | if id == 0 || generation != u.generation || u.pending != id { | |||
| 182 | return | 245 | return | ||
| 183 | } | 246 | } | ||
| 184 | - | u.finishLocked(receipt.ActionID, ports.UIActionProcessed, boundary) | 247 | + | u.overlayAction = id |
| 248 | + | } | |||
| 249 | + | ||||
| 250 | + | // followOverlay retains the overlay-consumed action across the attachment swap | |||
| 251 | + | // a picker commit started, so it settles on the destination's first committed | |||
| 252 | + | // publication. | |||
| 253 | + | func (u *UI) followOverlay(generation uint64) { | |||
| 254 | + | u.mu.Lock() | |||
| 255 | + | id := u.overlayAction | |||
| 256 | + | u.mu.Unlock() | |||
| 257 | + | if id != 0 { | |||
| 258 | + | u.follow(generation, id) | |||
| 259 | + | } | |||
| 260 | + | } | |||
| 261 | + | ||||
| 262 | + | // settleOverlay completes one overlay-consumed action at the supervisor's | |||
| 263 | + | // event-loop boundary: the picker applied its input and repainted. A followed | |||
| 264 | + | // action is left to the destination publication. | |||
| 265 | + | func (u *UI) settleOverlay(generation, id uint64) { | |||
| 266 | + | snapshot, err := u.state.Snapshot() | |||
| 267 | + | u.mu.Lock() | |||
| 268 | + | defer u.mu.Unlock() | |||
| 269 | + | if id == 0 || u.overlayAction != id || u.pending != id || generation != u.generation { | |||
| 270 | + | return | |||
| 271 | + | } | |||
| 272 | + | if u.handoff != nil && u.handoff.actionID == id { | |||
| 273 | + | return | |||
| 274 | + | } | |||
| 275 | + | boundary := u.boundary | |||
| 276 | + | if err == nil && snapshot.Context.Generation == generation && snapshot.Context.Status == ports.UIStatusAttached { | |||
| 277 | + | boundary = ports.UIActionResult{Revision: snapshot.Revision, Context: snapshot.Context} | |||
| 278 | + | } | |||
| 279 | + | u.finishLocked(id, ports.UIActionProcessed, boundary) | |||
| 185 | } | 280 | } | ||
| 186 | 281 | ||||
| 187 | func (u *UI) Action(ctx context.Context, request ports.UIActionRequest) (ports.UIActionResult, error) { | 282 | func (u *UI) Action(ctx context.Context, request ports.UIActionRequest) (ports.UIActionResult, error) { | ||