fix(client): keep ui-driver actions pending across daemon navigation
A driver action that makes the daemon navigate now completes on the destination's first attached publication instead of failing as outcome unknown when the source attachment ends.
- commit
- 9a0770dfb57f1f54a5c74acb5a973e88c4c19813
- author
- brice · · 2026-09-23 01:01 UTC
- parent
- 1cfa521
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +50 -8
M internal/usecase/client/supervisor_routes.go +19 -0
| @@ -71,6 +71,9 @@ func (s *Supervisor) settleDaemonNavigation(service ports.BrokerService, overlay | |||
| 71 | 71 | if !ok { | |
| 72 | 72 | return | |
| 73 | 73 | } | |
| 74 | + | if actionID := navigationCauseActionID(message); actionID != 0 && s.cfg.UI != nil { | |
| 75 | + | s.cfg.UI.follow(overlay.run.fg.uiGeneration, actionID) | |
| 76 | + | } | |
| 74 | 77 | if sameAttachmentTarget(overlay.request, s.attachments.committedTargetOrZero(), target.request) && target.tab.stopped == nil { | |
| 75 | 78 | _, current, _ := s.attachments.committedView() | |
| 76 | 79 | if target.tab.preferred != "" && target.tab.preferred != current { | |
| @@ -86,6 +89,19 @@ func (s *Supervisor) settleDaemonNavigation(service ports.BrokerService, overlay | |||
| 86 | 89 | s.attachments.requestDetach(overlay.run.token) | |
| 87 | 90 | } | |
| 88 | 91 | ||
| 92 | + | func navigationCauseActionID(message protocol.ServerMessage) uint64 { | |
| 93 | + | switch action := message.(type) { | |
| 94 | + | case protocol.RouteNavigationAction: | |
| 95 | + | return action.CauseActionID | |
| 96 | + | case protocol.RouteCreateSessionAction: | |
| 97 | + | return action.CauseActionID | |
| 98 | + | case protocol.AttachTarget: | |
| 99 | + | return action.CauseActionID | |
| 100 | + | default: | |
| 101 | + | return 0 | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | ||
| 89 | 105 | // takeSettledNavigation adopts a navigation request the worker handed over | |
| 90 | 106 | // just before its attachment ended, such as a close-and-dial handoff whose | |
| 91 | 107 | // daemon detached the source right after sending it. | |
| @@ -95,6 +111,9 @@ func (s *Supervisor) takeSettledNavigation(service ports.BrokerService, token At | |||
| 95 | 111 | return | |
| 96 | 112 | } | |
| 97 | 113 | if target, ok := s.resolveDaemonNavigation(service, token, request, message); ok { | |
| 114 | + | if actionID := navigationCauseActionID(message); actionID != 0 && s.cfg.UI != nil { | |
| 115 | + | s.cfg.UI.follow(s.cfg.UI.generation, actionID) | |
| 116 | + | } | |
| 98 | 117 | s.pendingSwap = &target | |
| 99 | 118 | } | |
| 100 | 119 | } | |
| @@ -71,6 +71,9 @@ func (s *Supervisor) settleDaemonNavigation(service ports.BrokerService, overlay | |||||
| 71 | if !ok { | 71 | if !ok { | ||
| 72 | return | 72 | return | ||
| 73 | } | 73 | } | ||
| 74 | + | if actionID := navigationCauseActionID(message); actionID != 0 && s.cfg.UI != nil { | |||
| 75 | + | s.cfg.UI.follow(overlay.run.fg.uiGeneration, actionID) | |||
| 76 | + | } | |||
| 74 | if sameAttachmentTarget(overlay.request, s.attachments.committedTargetOrZero(), target.request) && target.tab.stopped == nil { | 77 | if sameAttachmentTarget(overlay.request, s.attachments.committedTargetOrZero(), target.request) && target.tab.stopped == nil { | ||
| 75 | _, current, _ := s.attachments.committedView() | 78 | _, current, _ := s.attachments.committedView() | ||
| 76 | if target.tab.preferred != "" && target.tab.preferred != current { | 79 | if target.tab.preferred != "" && target.tab.preferred != current { | ||
| @@ -86,6 +89,19 @@ func (s *Supervisor) settleDaemonNavigation(service ports.BrokerService, overlay | |||||
| 86 | s.attachments.requestDetach(overlay.run.token) | 89 | s.attachments.requestDetach(overlay.run.token) | ||
| 87 | } | 90 | } | ||
| 88 | 91 | ||||
| 92 | + | func navigationCauseActionID(message protocol.ServerMessage) uint64 { | |||
| 93 | + | switch action := message.(type) { | |||
| 94 | + | case protocol.RouteNavigationAction: | |||
| 95 | + | return action.CauseActionID | |||
| 96 | + | case protocol.RouteCreateSessionAction: | |||
| 97 | + | return action.CauseActionID | |||
| 98 | + | case protocol.AttachTarget: | |||
| 99 | + | return action.CauseActionID | |||
| 100 | + | default: | |||
| 101 | + | return 0 | |||
| 102 | + | } | |||
| 103 | + | } | |||
| 104 | + | ||||
| 89 | // takeSettledNavigation adopts a navigation request the worker handed over | 105 | // takeSettledNavigation adopts a navigation request the worker handed over | ||
| 90 | // just before its attachment ended, such as a close-and-dial handoff whose | 106 | // just before its attachment ended, such as a close-and-dial handoff whose | ||
| 91 | // daemon detached the source right after sending it. | 107 | // daemon detached the source right after sending it. | ||
| @@ -95,6 +111,9 @@ func (s *Supervisor) takeSettledNavigation(service ports.BrokerService, token At | |||||
| 95 | return | 111 | return | ||
| 96 | } | 112 | } | ||
| 97 | if target, ok := s.resolveDaemonNavigation(service, token, request, message); ok { | 113 | if target, ok := s.resolveDaemonNavigation(service, token, request, message); ok { | ||
| 114 | + | if actionID := navigationCauseActionID(message); actionID != 0 && s.cfg.UI != nil { | |||
| 115 | + | s.cfg.UI.follow(s.cfg.UI.generation, actionID) | |||
| 116 | + | } | |||
| 98 | s.pendingSwap = &target | 117 | s.pendingSwap = &target | ||
| 99 | } | 118 | } | ||
| 100 | } | 119 | } | ||
M internal/usecase/client/ui_actions.go +31 -8
| @@ -27,13 +27,30 @@ func (u *UI) bindForeground(ctx context.Context, input *terminalInputPump, consu | |||
| 27 | 27 | return u.generation | |
| 28 | 28 | } | |
| 29 | 29 | ||
| 30 | - | // releaseForeground retires exactly the binding installed for generation. It | |
| 31 | - | // is called when the supervisor begins finalizing the attachment, before the | |
| 32 | - | // pump claim can return to the picker. It publishes nothing itself: the | |
| 33 | - | // supervisor owns the presentation fence, and its return to Picker runs after | |
| 34 | - | // this teardown (join, revoke, stream close) has drained the last in-flight | |
| 35 | - | // transaction, so no unattached presentation is ever committed while a session | |
| 36 | - | // frame is still being written. | |
| 30 | + | // follow retains the accepted input action across an exact daemon navigation. | |
| 31 | + | // It waits for the destination's first committed publication, not merely a | |
| 32 | + | // successful send on the source attachment. | |
| 33 | + | func (u *UI) follow(generation, actionID uint64) bool { | |
| 34 | + | u.mu.Lock() | |
| 35 | + | defer u.mu.Unlock() | |
| 36 | + | record, ok := u.records[actionID] | |
| 37 | + | if !ok || actionID == 0 || generation != u.generation || record.Context.Generation != generation || | |
| 38 | + | (record.Status != ports.UIActionPending && record.Status != ports.UIActionProcessed) { | |
| 39 | + | return false | |
| 40 | + | } | |
| 41 | + | if u.pending != 0 && u.pending != actionID { | |
| 42 | + | u.finishLocked(u.pending, ports.UIActionOutcomeUnknown, ports.UIActionResult{}) | |
| 43 | + | } | |
| 44 | + | record.Status = ports.UIActionPending | |
| 45 | + | u.records[actionID] = record | |
| 46 | + | u.pending = actionID | |
| 47 | + | u.handoff = &uiActionHandoff{actionID: actionID, sourceGeneration: generation} | |
| 48 | + | u.signalLocked() | |
| 49 | + | return true | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // releaseForeground retires exactly the binding installed for generation. | |
| 53 | + | // The supervisor owns the presentation fence after the foreground drains. | |
| 37 | 54 | func (u *UI) releaseForeground(generation uint64) { | |
| 38 | 55 | u.mu.Lock() | |
| 39 | 56 | if generation != u.generation { | |
| @@ -44,7 +61,7 @@ func (u *UI) releaseForeground(generation uint64) { | |||
| 44 | 61 | u.consumer = 0 | |
| 45 | 62 | u.foreground = nil | |
| 46 | 63 | u.boundary = ports.UIActionResult{} | |
| 47 | - | if u.pending != 0 { | |
| 64 | + | if u.pending != 0 && (u.handoff == nil || u.handoff.actionID != u.pending) { | |
| 48 | 65 | u.finishLocked(u.pending, ports.UIActionOutcomeUnknown, ports.UIActionResult{}) | |
| 49 | 66 | } | |
| 50 | 67 | u.signalLocked() | |
| @@ -112,6 +129,12 @@ func (u *UI) published(generation uint64) { | |||
| 112 | 129 | return | |
| 113 | 130 | } | |
| 114 | 131 | u.boundary = ports.UIActionResult{Revision: snapshot.Revision, Context: snapshot.Context} | |
| 132 | + | if u.handoff != nil && u.handoff.destinationGeneration == generation && snapshot.Context.Status == ports.UIStatusAttached { | |
| 133 | + | u.handoff.boundary = u.boundary | |
| 134 | + | if u.dispatched[u.handoff.actionID] { | |
| 135 | + | u.finishLocked(u.handoff.actionID, ports.UIActionProcessed, u.boundary) | |
| 136 | + | } | |
| 137 | + | } | |
| 115 | 138 | u.signalLocked() | |
| 116 | 139 | } | |
| 117 | 140 | ||
| @@ -27,13 +27,30 @@ func (u *UI) bindForeground(ctx context.Context, input *terminalInputPump, consu | |||||
| 27 | return u.generation | 27 | return u.generation | ||
| 28 | } | 28 | } | ||
| 29 | 29 | ||||
| 30 | - | // releaseForeground retires exactly the binding installed for generation. It | 30 | + | // follow retains the accepted input action across an exact daemon navigation. |
| 31 | - | // is called when the supervisor begins finalizing the attachment, before the | 31 | + | // It waits for the destination's first committed publication, not merely a |
| 32 | - | // pump claim can return to the picker. It publishes nothing itself: the | 32 | + | // successful send on the source attachment. |
| 33 | - | // supervisor owns the presentation fence, and its return to Picker runs after | 33 | + | func (u *UI) follow(generation, actionID uint64) bool { |
| 34 | - | // this teardown (join, revoke, stream close) has drained the last in-flight | 34 | + | u.mu.Lock() |
| 35 | - | // transaction, so no unattached presentation is ever committed while a session | 35 | + | defer u.mu.Unlock() |
| 36 | - | // frame is still being written. | 36 | + | record, ok := u.records[actionID] |
| 37 | + | if !ok || actionID == 0 || generation != u.generation || record.Context.Generation != generation || | |||
| 38 | + | (record.Status != ports.UIActionPending && record.Status != ports.UIActionProcessed) { | |||
| 39 | + | return false | |||
| 40 | + | } | |||
| 41 | + | if u.pending != 0 && u.pending != actionID { | |||
| 42 | + | u.finishLocked(u.pending, ports.UIActionOutcomeUnknown, ports.UIActionResult{}) | |||
| 43 | + | } | |||
| 44 | + | record.Status = ports.UIActionPending | |||
| 45 | + | u.records[actionID] = record | |||
| 46 | + | u.pending = actionID | |||
| 47 | + | u.handoff = &uiActionHandoff{actionID: actionID, sourceGeneration: generation} | |||
| 48 | + | u.signalLocked() | |||
| 49 | + | return true | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | // releaseForeground retires exactly the binding installed for generation. | |||
| 53 | + | // The supervisor owns the presentation fence after the foreground drains. | |||
| 37 | func (u *UI) releaseForeground(generation uint64) { | 54 | func (u *UI) releaseForeground(generation uint64) { | ||
| 38 | u.mu.Lock() | 55 | u.mu.Lock() | ||
| 39 | if generation != u.generation { | 56 | if generation != u.generation { | ||
| @@ -44,7 +61,7 @@ func (u *UI) releaseForeground(generation uint64) { | |||||
| 44 | u.consumer = 0 | 61 | u.consumer = 0 | ||
| 45 | u.foreground = nil | 62 | u.foreground = nil | ||
| 46 | u.boundary = ports.UIActionResult{} | 63 | u.boundary = ports.UIActionResult{} | ||
| 47 | - | if u.pending != 0 { | 64 | + | if u.pending != 0 && (u.handoff == nil || u.handoff.actionID != u.pending) { |
| 48 | u.finishLocked(u.pending, ports.UIActionOutcomeUnknown, ports.UIActionResult{}) | 65 | u.finishLocked(u.pending, ports.UIActionOutcomeUnknown, ports.UIActionResult{}) | ||
| 49 | } | 66 | } | ||
| 50 | u.signalLocked() | 67 | u.signalLocked() | ||
| @@ -112,6 +129,12 @@ func (u *UI) published(generation uint64) { | |||||
| 112 | return | 129 | return | ||
| 113 | } | 130 | } | ||
| 114 | u.boundary = ports.UIActionResult{Revision: snapshot.Revision, Context: snapshot.Context} | 131 | u.boundary = ports.UIActionResult{Revision: snapshot.Revision, Context: snapshot.Context} | ||
| 132 | + | if u.handoff != nil && u.handoff.destinationGeneration == generation && snapshot.Context.Status == ports.UIStatusAttached { | |||
| 133 | + | u.handoff.boundary = u.boundary | |||
| 134 | + | if u.dispatched[u.handoff.actionID] { | |||
| 135 | + | u.finishLocked(u.handoff.actionID, ports.UIActionProcessed, u.boundary) | |||
| 136 | + | } | |||
| 137 | + | } | |||
| 115 | u.signalLocked() | 138 | u.signalLocked() | ||
| 116 | } | 139 | } | ||
| 117 | 140 | ||||