detached 1af5579

fix(client): settle ui-driver actions across same-peer session switches

A palette "Switch to session" on the serving daemon is an in-place
same-peer switch: the attachment and its UI generation survive, so the
daemon-navigation handoff (which completes on the next foreground
binding) never fired, and the daemon's UI fence was left on the source
session. The driver's Enter action therefore stayed pending until it
timed out even though the session had switched.

The worker now follows the offer's cause action, marks the handoff's
destination on the first output committed for the confirmed target, and
fails it on a matching SamePeerSwitchFailure, restoring main's
follow/destinationFull semantics.
commit
1af557980bdb4b7b12aa341f2b2ef864b5fd6074
author
brice · · 2026-09-23 02:29 UTC
parent
d84053d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +218 -0
M internal/usecase/client/attachment_session.go +43 -0
@@ -302,6 +302,8 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
302302 navigationReplies = overlay.navigationReplies()
303303 }
304304 var samePeerRequests uint64
305+ var samePeerSwitch *samePeerSwitchPending
306+ samePeerUI := attachmentSamePeerUI(fg)
305307 picker := &attachmentMovePicker{worker: w, fg: fg, overlay: overlay, stream: stream, size: w.cfg.Geometry.Size, move: newMovePickerOverlay(w.cfg.TrueColor)}
306308 defer picker.stopEscape()
307309 input := newAttachmentInput(ctx, w, fg, stream, picker)
@@ -369,6 +371,14 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
369371 }
370372 state = next
371373 outputResetRequested = false
374+ if samePeerSwitch != nil && state.context.Route.Target == samePeerSwitch.target {
375+ // The in-place destination committed: its publication
376+ // settles the action that caused the switch.
377+ samePeerSwitch = nil
378+ if samePeerUI != nil {
379+ samePeerUI.uiSamePeerArrived()
380+ }
381+ }
372382 attachmentNoteCommitted(fg, state.context)
373383 if err := fg.Output(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusAttached), typed.Data); err != nil {
374384 return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: publishing output: %w", err)}
@@ -413,6 +423,10 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
413423 if request.Validate() != nil {
414424 continue
415425 }
426+ if samePeerUI != nil {
427+ samePeerUI.uiFollowSamePeer(typed.CauseActionID)
428+ }
429+ samePeerSwitch = &samePeerSwitchPending{requestID: request.RequestID, target: request.Target}
416430 if err := w.send(ctx, fg, stream, request); err != nil {
417431 return w.settle(ctx, fg, stream, token, err)
418432 }
@@ -421,6 +435,14 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
421435 if overlay != nil {
422436 overlay.requestNavigation(typed)
423437 }
438+ case protocol.SamePeerSwitchFailure:
439+ // The daemon refused the confirmed switch and kept the source.
440+ if samePeerSwitch != nil && typed.RequestID == samePeerSwitch.requestID {
441+ samePeerSwitch = nil
442+ if samePeerUI != nil {
443+ samePeerUI.uiSamePeerFailed()
444+ }
445+ }
424446 case protocol.RouteNavigationAction, protocol.RouteCreateSessionAction:
425447 // The daemon asks the client to navigate: the supervisor
426448 // resolves it through the broker catalogue.
@@ -535,6 +557,27 @@ func attachmentUIReceipt(fg AttachmentForeground, receipt protocol.UIReceipt) {
535557 }
536558 }
537559
560+// attachmentSamePeerForeground is the optional UI seam of an in-place
561+// same-peer switch: the attachment survives, so the driver action that caused
562+// the offer settles on the destination's first committed output.
563+type attachmentSamePeerForeground interface {
564+ uiFollowSamePeer(actionID uint64)
565+ uiSamePeerArrived()
566+ uiSamePeerFailed()
567+}
568+
569+func attachmentSamePeerUI(fg AttachmentForeground) attachmentSamePeerForeground {
570+ ui, _ := fg.(attachmentSamePeerForeground)
571+ return ui
572+}
573+
574+// samePeerSwitchPending is the one confirmed same-peer switch awaiting its
575+// destination output or the daemon's refusal.
576+type samePeerSwitchPending struct {
577+ requestID uint64
578+ target protocol.ExactSessionTarget
579+}
580+
538581 // pumpAttachmentInput forwards each authorized delivery to the attached loop.
539582 // The loop decides whether an overlay owns it or it becomes session Input, and
540583 // sequences only the deliveries that actually reach the session.
@@ -302,6 +302,8 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
302 navigationReplies = overlay.navigationReplies()302 navigationReplies = overlay.navigationReplies()
303 }303 }
304 var samePeerRequests uint64304 var samePeerRequests uint64
305+ var samePeerSwitch *samePeerSwitchPending
306+ samePeerUI := attachmentSamePeerUI(fg)
305 picker := &attachmentMovePicker{worker: w, fg: fg, overlay: overlay, stream: stream, size: w.cfg.Geometry.Size, move: newMovePickerOverlay(w.cfg.TrueColor)}307 picker := &attachmentMovePicker{worker: w, fg: fg, overlay: overlay, stream: stream, size: w.cfg.Geometry.Size, move: newMovePickerOverlay(w.cfg.TrueColor)}
306 defer picker.stopEscape()308 defer picker.stopEscape()
307 input := newAttachmentInput(ctx, w, fg, stream, picker)309 input := newAttachmentInput(ctx, w, fg, stream, picker)
@@ -369,6 +371,14 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
369 }371 }
370 state = next372 state = next
371 outputResetRequested = false373 outputResetRequested = false
374+ if samePeerSwitch != nil && state.context.Route.Target == samePeerSwitch.target {
375+ // The in-place destination committed: its publication
376+ // settles the action that caused the switch.
377+ samePeerSwitch = nil
378+ if samePeerUI != nil {
379+ samePeerUI.uiSamePeerArrived()
380+ }
381+ }
372 attachmentNoteCommitted(fg, state.context)382 attachmentNoteCommitted(fg, state.context)
373 if err := fg.Output(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusAttached), typed.Data); err != nil {383 if err := fg.Output(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusAttached), typed.Data); err != nil {
374 return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: publishing output: %w", err)}384 return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: publishing output: %w", err)}
@@ -413,6 +423,10 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
413 if request.Validate() != nil {423 if request.Validate() != nil {
414 continue424 continue
415 }425 }
426+ if samePeerUI != nil {
427+ samePeerUI.uiFollowSamePeer(typed.CauseActionID)
428+ }
429+ samePeerSwitch = &samePeerSwitchPending{requestID: request.RequestID, target: request.Target}
416 if err := w.send(ctx, fg, stream, request); err != nil {430 if err := w.send(ctx, fg, stream, request); err != nil {
417 return w.settle(ctx, fg, stream, token, err)431 return w.settle(ctx, fg, stream, token, err)
418 }432 }
@@ -421,6 +435,14 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
421 if overlay != nil {435 if overlay != nil {
422 overlay.requestNavigation(typed)436 overlay.requestNavigation(typed)
423 }437 }
438+ case protocol.SamePeerSwitchFailure:
439+ // The daemon refused the confirmed switch and kept the source.
440+ if samePeerSwitch != nil && typed.RequestID == samePeerSwitch.requestID {
441+ samePeerSwitch = nil
442+ if samePeerUI != nil {
443+ samePeerUI.uiSamePeerFailed()
444+ }
445+ }
424 case protocol.RouteNavigationAction, protocol.RouteCreateSessionAction:446 case protocol.RouteNavigationAction, protocol.RouteCreateSessionAction:
425 // The daemon asks the client to navigate: the supervisor447 // The daemon asks the client to navigate: the supervisor
426 // resolves it through the broker catalogue.448 // resolves it through the broker catalogue.
@@ -535,6 +557,27 @@ func attachmentUIReceipt(fg AttachmentForeground, receipt protocol.UIReceipt) {
535 }557 }
536 }558 }
537 559
560+// attachmentSamePeerForeground is the optional UI seam of an in-place
561+// same-peer switch: the attachment survives, so the driver action that caused
562+// the offer settles on the destination's first committed output.
563+type attachmentSamePeerForeground interface {
564+ uiFollowSamePeer(actionID uint64)
565+ uiSamePeerArrived()
566+ uiSamePeerFailed()
567+}
568+
569+func attachmentSamePeerUI(fg AttachmentForeground) attachmentSamePeerForeground {
570+ ui, _ := fg.(attachmentSamePeerForeground)
571+ return ui
572+}
573+
574+// samePeerSwitchPending is the one confirmed same-peer switch awaiting its
575+// destination output or the daemon's refusal.
576+type samePeerSwitchPending struct {
577+ requestID uint64
578+ target protocol.ExactSessionTarget
579+}
580+
538 // pumpAttachmentInput forwards each authorized delivery to the attached loop.581 // pumpAttachmentInput forwards each authorized delivery to the attached loop.
539 // The loop decides whether an overlay owns it or it becomes session Input, and582 // The loop decides whether an overlay owns it or it becomes session Input, and
540 // sequences only the deliveries that actually reach the session.583 // sequences only the deliveries that actually reach the session.
M internal/usecase/client/attachment_worker.go +22 -0
@@ -1109,6 +1109,28 @@ func (f *attachmentForeground) uiReceipt(receipt protocol.UIReceipt) {
11091109 }
11101110 }
11111111
1112+// uiFollowSamePeer keeps the driver action that caused a same-peer offer
1113+// pending until the in-place destination commits.
1114+func (f *attachmentForeground) uiFollowSamePeer(actionID uint64) {
1115+ if actionID != 0 && f.uiGeneration != 0 && f.host != nil && f.host.actionUI != nil {
1116+ f.host.actionUI.follow(f.uiGeneration, actionID)
1117+ }
1118+}
1119+
1120+// uiSamePeerArrived runs before the destination's first output is published.
1121+func (f *attachmentForeground) uiSamePeerArrived() {
1122+ if f.uiGeneration != 0 && f.host != nil && f.host.actionUI != nil {
1123+ f.host.actionUI.arriveInPlace(f.uiGeneration)
1124+ }
1125+}
1126+
1127+// uiSamePeerFailed fails the followed action of a refused same-peer switch.
1128+func (f *attachmentForeground) uiSamePeerFailed() {
1129+ if f.uiGeneration != 0 && f.host != nil && f.host.actionUI != nil {
1130+ f.host.actionUI.failInPlace(f.uiGeneration)
1131+ }
1132+}
1133+
11121134 // Stream is the supervisor-admitted logical stream this worker owns.
11131135 func (f *attachmentForeground) Stream() ports.BrokerLogicalConnection { return f.stream }
11141136
@@ -1109,6 +1109,28 @@ func (f *attachmentForeground) uiReceipt(receipt protocol.UIReceipt) {
1109 }1109 }
1110 }1110 }
1111 1111
1112+// uiFollowSamePeer keeps the driver action that caused a same-peer offer
1113+// pending until the in-place destination commits.
1114+func (f *attachmentForeground) uiFollowSamePeer(actionID uint64) {
1115+ if actionID != 0 && f.uiGeneration != 0 && f.host != nil && f.host.actionUI != nil {
1116+ f.host.actionUI.follow(f.uiGeneration, actionID)
1117+ }
1118+}
1119+
1120+// uiSamePeerArrived runs before the destination's first output is published.
1121+func (f *attachmentForeground) uiSamePeerArrived() {
1122+ if f.uiGeneration != 0 && f.host != nil && f.host.actionUI != nil {
1123+ f.host.actionUI.arriveInPlace(f.uiGeneration)
1124+ }
1125+}
1126+
1127+// uiSamePeerFailed fails the followed action of a refused same-peer switch.
1128+func (f *attachmentForeground) uiSamePeerFailed() {
1129+ if f.uiGeneration != 0 && f.host != nil && f.host.actionUI != nil {
1130+ f.host.actionUI.failInPlace(f.uiGeneration)
1131+ }
1132+}
1133+
1112 // Stream is the supervisor-admitted logical stream this worker owns.1134 // Stream is the supervisor-admitted logical stream this worker owns.
1113 func (f *attachmentForeground) Stream() ports.BrokerLogicalConnection { return f.stream }1135 func (f *attachmentForeground) Stream() ports.BrokerLogicalConnection { return f.stream }
1114 1136
M internal/usecase/client/supervisor_routes_test.go +128 -0
@@ -262,3 +262,131 @@ func TestSupervisorRouteSnapshotExcludesServingHost(t *testing.T) {
262262 require.Equal(t, "example", published.ActiveEntry.HostLabel)
263263 require.True(t, published.Home.IsZero(), "only a local attachment is home")
264264 }
265+
266+// TestSupervisorSamePeerSwitchSettlesUIAction pins the driver action that
267+// causes a daemon same-peer switch (palette "Switch to session" on the serving
268+// daemon): the attachment is kept, so the action must follow the offer and
269+// settle on the destination's first committed output — never on a source
270+// publication or a source-boundary receipt — and a refused switch fails it.
271+func TestSupervisorSamePeerSwitchSettlesUIAction(t *testing.T) {
272+ beta := protocol.ExactSessionTarget{LifecycleID: pickerTestLifecycle(2), SessionName: "beta"}
273+ tests := []struct {
274+ name string
275+ // settle delivers the daemon's answer to the confirmed switch.
276+ settle func(*sessionTestStream, protocol.SamePeerSwitchRequest)
277+ // sourceReceipt delivers a daemon receipt on the source boundary; the
278+ // daemon may also lose the source fence and never answer it.
279+ sourceReceipt bool
280+ wantErr ports.UIErrorCode
281+ }{
282+ {
283+ name: "destination output completes the action without a daemon receipt",
284+ settle: func(stream *sessionTestStream, _ protocol.SamePeerSwitchRequest) {
285+ destination := sessionTestOutput(4, "\x1b[Hbeta")
286+ destination.Epoch = 2
287+ destination.Context.Route.Target = beta
288+ stream.deliver(destination)
289+ },
290+ },
291+ {
292+ name: "a source-boundary receipt waits for the destination output",
293+ sourceReceipt: true,
294+ settle: func(stream *sessionTestStream, _ protocol.SamePeerSwitchRequest) {
295+ destination := sessionTestOutput(4, "\x1b[Hbeta")
296+ destination.Epoch = 2
297+ destination.Context.Route.Target = beta
298+ stream.deliver(destination)
299+ },
300+ },
301+ {
302+ name: "a refused switch fails the action",
303+ sourceReceipt: true,
304+ settle: func(stream *sessionTestStream, request protocol.SamePeerSwitchRequest) {
305+ stream.deliver(protocol.SamePeerSwitchFailure{RequestID: request.RequestID, Code: protocol.SamePeerSwitchStaleTarget})
306+ },
307+ wantErr: ports.UIErrNavigationFailed,
308+ },
309+ }
310+ for _, tt := range tests {
311+ t.Run(tt.name, func(t *testing.T) {
312+ picker := newAttachTestPicker()
313+ var ui *UI
314+ harness := startAttachHarnessConfig(t, picker, func(cfg *SupervisorConfig) {
315+ ui = NewUI(cfg.Terminal.(ports.UIState), cfg.Clock)
316+ cfg.UI = ui
317+ })
318+ stream := attachLiveSession(t, harness, picker)
319+ attached := sessionTestOutput(2, "\x1b[Hattached")
320+ attached.Full, attached.Base, attached.New = false, 1, 2
321+ stream.deliver(attached)
322+ var snapshot ports.UISnapshot
323+ require.Eventually(t, func() bool {
324+ var err error
325+ snapshot, err = ui.Capture(ui.Handle())
326+ return err == nil && snapshot.Context.Status == ports.UIStatusAttached && snapshot.Context.ViewPublication == 2
327+ }, 5*time.Second, time.Millisecond)
328+
329+ type actionOutcome struct {
330+ result ports.UIActionResult
331+ err error
332+ }
333+ done := make(chan actionOutcome, 1)
334+ go func() {
335+ result, err := ui.Action(t.Context(), ports.UIActionRequest{Attachment: ui.Handle(), Generation: snapshot.Context.Generation, Keys: []string{"Enter"}})
336+ done <- actionOutcome{result: result, err: err}
337+ }()
338+ var actionID uint64
339+ require.Eventually(t, func() bool {
340+ for _, message := range stream.messages() {
341+ if fence, ok := message.(protocol.UIFence); ok && fence.ActionID != 0 {
342+ actionID = fence.ActionID
343+ return true
344+ }
345+ }
346+ return false
347+ }, 5*time.Second, time.Millisecond, "the action never reached the daemon")
348+
349+ target := beta
350+ stream.deliver(protocol.AttachTarget{Session: "beta", Intent: protocol.IntentAttach, ExactTarget: &target, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, SamePeer: true, CauseActionID: actionID})
351+ request := awaitSent(t, stream, "SamePeerSwitchRequest", isSent[protocol.SamePeerSwitchRequest]).(protocol.SamePeerSwitchRequest)
352+
353+ // A source publication and a receipt on the source boundary arrive
354+ // before the destination: neither may settle the navigating action.
355+ source := sessionTestOutput(3, "\x1b[Hsource")
356+ source.Full, source.Base, source.New = false, 2, 3
357+ stream.deliver(source)
358+ if tt.sourceReceipt {
359+ stream.deliver(protocol.UIReceipt{ActionID: actionID, Epoch: 1, State: 3, ViewPublication: 3, Outcome: protocol.UIReceiptProcessed})
360+ }
361+ require.Eventually(t, func() bool {
362+ ui.mu.Lock()
363+ defer ui.mu.Unlock()
364+ return ui.boundary.Context.ViewPublication == 3
365+ }, 5*time.Second, time.Millisecond, "the source publication was never committed")
366+ select {
367+ case outcome := <-done:
368+ t.Fatalf("the action settled before the destination: %+v %v", outcome.result, outcome.err)
369+ default:
370+ }
371+
372+ tt.settle(stream, request)
373+ var outcome actionOutcome
374+ select {
375+ case outcome = <-done:
376+ case <-time.After(5 * time.Second):
377+ t.Fatal("the same-peer switch never settled the UI action")
378+ }
379+ if tt.wantErr != "" {
380+ var uiErr *ports.UIError
381+ require.ErrorAs(t, outcome.err, &uiErr)
382+ require.Equal(t, tt.wantErr, uiErr.Code)
383+ require.Equal(t, actionID, uiErr.ActionID)
384+ return
385+ }
386+ require.NoError(t, outcome.err)
387+ require.Equal(t, ports.UIActionProcessed, outcome.result.Status)
388+ require.Equal(t, beta, outcome.result.Context.Route.Target)
389+ require.Equal(t, snapshot.Context.Generation, outcome.result.Context.Generation, "an in-place switch keeps the actionable generation")
390+ })
391+ }
392+}
@@ -262,3 +262,131 @@ func TestSupervisorRouteSnapshotExcludesServingHost(t *testing.T) {
262 require.Equal(t, "example", published.ActiveEntry.HostLabel)262 require.Equal(t, "example", published.ActiveEntry.HostLabel)
263 require.True(t, published.Home.IsZero(), "only a local attachment is home")263 require.True(t, published.Home.IsZero(), "only a local attachment is home")
264 }264 }
265+
266+// TestSupervisorSamePeerSwitchSettlesUIAction pins the driver action that
267+// causes a daemon same-peer switch (palette "Switch to session" on the serving
268+// daemon): the attachment is kept, so the action must follow the offer and
269+// settle on the destination's first committed output — never on a source
270+// publication or a source-boundary receipt — and a refused switch fails it.
271+func TestSupervisorSamePeerSwitchSettlesUIAction(t *testing.T) {
272+ beta := protocol.ExactSessionTarget{LifecycleID: pickerTestLifecycle(2), SessionName: "beta"}
273+ tests := []struct {
274+ name string
275+ // settle delivers the daemon's answer to the confirmed switch.
276+ settle func(*sessionTestStream, protocol.SamePeerSwitchRequest)
277+ // sourceReceipt delivers a daemon receipt on the source boundary; the
278+ // daemon may also lose the source fence and never answer it.
279+ sourceReceipt bool
280+ wantErr ports.UIErrorCode
281+ }{
282+ {
283+ name: "destination output completes the action without a daemon receipt",
284+ settle: func(stream *sessionTestStream, _ protocol.SamePeerSwitchRequest) {
285+ destination := sessionTestOutput(4, "\x1b[Hbeta")
286+ destination.Epoch = 2
287+ destination.Context.Route.Target = beta
288+ stream.deliver(destination)
289+ },
290+ },
291+ {
292+ name: "a source-boundary receipt waits for the destination output",
293+ sourceReceipt: true,
294+ settle: func(stream *sessionTestStream, _ protocol.SamePeerSwitchRequest) {
295+ destination := sessionTestOutput(4, "\x1b[Hbeta")
296+ destination.Epoch = 2
297+ destination.Context.Route.Target = beta
298+ stream.deliver(destination)
299+ },
300+ },
301+ {
302+ name: "a refused switch fails the action",
303+ sourceReceipt: true,
304+ settle: func(stream *sessionTestStream, request protocol.SamePeerSwitchRequest) {
305+ stream.deliver(protocol.SamePeerSwitchFailure{RequestID: request.RequestID, Code: protocol.SamePeerSwitchStaleTarget})
306+ },
307+ wantErr: ports.UIErrNavigationFailed,
308+ },
309+ }
310+ for _, tt := range tests {
311+ t.Run(tt.name, func(t *testing.T) {
312+ picker := newAttachTestPicker()
313+ var ui *UI
314+ harness := startAttachHarnessConfig(t, picker, func(cfg *SupervisorConfig) {
315+ ui = NewUI(cfg.Terminal.(ports.UIState), cfg.Clock)
316+ cfg.UI = ui
317+ })
318+ stream := attachLiveSession(t, harness, picker)
319+ attached := sessionTestOutput(2, "\x1b[Hattached")
320+ attached.Full, attached.Base, attached.New = false, 1, 2
321+ stream.deliver(attached)
322+ var snapshot ports.UISnapshot
323+ require.Eventually(t, func() bool {
324+ var err error
325+ snapshot, err = ui.Capture(ui.Handle())
326+ return err == nil && snapshot.Context.Status == ports.UIStatusAttached && snapshot.Context.ViewPublication == 2
327+ }, 5*time.Second, time.Millisecond)
328+
329+ type actionOutcome struct {
330+ result ports.UIActionResult
331+ err error
332+ }
333+ done := make(chan actionOutcome, 1)
334+ go func() {
335+ result, err := ui.Action(t.Context(), ports.UIActionRequest{Attachment: ui.Handle(), Generation: snapshot.Context.Generation, Keys: []string{"Enter"}})
336+ done <- actionOutcome{result: result, err: err}
337+ }()
338+ var actionID uint64
339+ require.Eventually(t, func() bool {
340+ for _, message := range stream.messages() {
341+ if fence, ok := message.(protocol.UIFence); ok && fence.ActionID != 0 {
342+ actionID = fence.ActionID
343+ return true
344+ }
345+ }
346+ return false
347+ }, 5*time.Second, time.Millisecond, "the action never reached the daemon")
348+
349+ target := beta
350+ stream.deliver(protocol.AttachTarget{Session: "beta", Intent: protocol.IntentAttach, ExactTarget: &target, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, SamePeer: true, CauseActionID: actionID})
351+ request := awaitSent(t, stream, "SamePeerSwitchRequest", isSent[protocol.SamePeerSwitchRequest]).(protocol.SamePeerSwitchRequest)
352+
353+ // A source publication and a receipt on the source boundary arrive
354+ // before the destination: neither may settle the navigating action.
355+ source := sessionTestOutput(3, "\x1b[Hsource")
356+ source.Full, source.Base, source.New = false, 2, 3
357+ stream.deliver(source)
358+ if tt.sourceReceipt {
359+ stream.deliver(protocol.UIReceipt{ActionID: actionID, Epoch: 1, State: 3, ViewPublication: 3, Outcome: protocol.UIReceiptProcessed})
360+ }
361+ require.Eventually(t, func() bool {
362+ ui.mu.Lock()
363+ defer ui.mu.Unlock()
364+ return ui.boundary.Context.ViewPublication == 3
365+ }, 5*time.Second, time.Millisecond, "the source publication was never committed")
366+ select {
367+ case outcome := <-done:
368+ t.Fatalf("the action settled before the destination: %+v %v", outcome.result, outcome.err)
369+ default:
370+ }
371+
372+ tt.settle(stream, request)
373+ var outcome actionOutcome
374+ select {
375+ case outcome = <-done:
376+ case <-time.After(5 * time.Second):
377+ t.Fatal("the same-peer switch never settled the UI action")
378+ }
379+ if tt.wantErr != "" {
380+ var uiErr *ports.UIError
381+ require.ErrorAs(t, outcome.err, &uiErr)
382+ require.Equal(t, tt.wantErr, uiErr.Code)
383+ require.Equal(t, actionID, uiErr.ActionID)
384+ return
385+ }
386+ require.NoError(t, outcome.err)
387+ require.Equal(t, ports.UIActionProcessed, outcome.result.Status)
388+ require.Equal(t, beta, outcome.result.Context.Route.Target)
389+ require.Equal(t, snapshot.Context.Generation, outcome.result.Context.Generation, "an in-place switch keeps the actionable generation")
390+ })
391+ }
392+}
M internal/usecase/client/ui_actions.go +25 -0
@@ -49,6 +49,31 @@ func (u *UI) follow(generation, actionID uint64) bool {
4949 return true
5050 }
5151
52+// arriveInPlace marks the destination of a followed same-peer switch as
53+// committed on the unchanged foreground generation. The switch keeps the
54+// attachment, so no new foreground binding names the destination: the worker
55+// calls this just before publishing the destination's first output, and that
56+// publication settles the action.
57+func (u *UI) arriveInPlace(generation uint64) {
58+ u.mu.Lock()
59+ defer u.mu.Unlock()
60+ if u.handoff == nil || generation != u.generation || u.handoff.sourceGeneration != generation || u.handoff.destinationGeneration != 0 {
61+ return
62+ }
63+ u.handoff.destinationGeneration = generation
64+}
65+
66+// failInPlace fails a followed same-peer switch the daemon refused: the source
67+// attachment is unchanged and the destination never commits.
68+func (u *UI) failInPlace(generation uint64) {
69+ u.mu.Lock()
70+ defer u.mu.Unlock()
71+ if u.handoff == nil || generation != u.generation || u.handoff.sourceGeneration != generation || u.handoff.destinationGeneration != 0 {
72+ return
73+ }
74+ u.finishLocked(u.handoff.actionID, ports.UIActionNavigationFailed, ports.UIActionResult{})
75+}
76+
5277 // releaseForeground retires exactly the binding installed for generation.
5378 // The supervisor owns the presentation fence after the foreground drains.
5479 func (u *UI) releaseForeground(generation uint64) {
@@ -49,6 +49,31 @@ func (u *UI) follow(generation, actionID uint64) bool {
49 return true49 return true
50 }50 }
51 51
52+// arriveInPlace marks the destination of a followed same-peer switch as
53+// committed on the unchanged foreground generation. The switch keeps the
54+// attachment, so no new foreground binding names the destination: the worker
55+// calls this just before publishing the destination's first output, and that
56+// publication settles the action.
57+func (u *UI) arriveInPlace(generation uint64) {
58+ u.mu.Lock()
59+ defer u.mu.Unlock()
60+ if u.handoff == nil || generation != u.generation || u.handoff.sourceGeneration != generation || u.handoff.destinationGeneration != 0 {
61+ return
62+ }
63+ u.handoff.destinationGeneration = generation
64+}
65+
66+// failInPlace fails a followed same-peer switch the daemon refused: the source
67+// attachment is unchanged and the destination never commits.
68+func (u *UI) failInPlace(generation uint64) {
69+ u.mu.Lock()
70+ defer u.mu.Unlock()
71+ if u.handoff == nil || generation != u.generation || u.handoff.sourceGeneration != generation || u.handoff.destinationGeneration != 0 {
72+ return
73+ }
74+ u.finishLocked(u.handoff.actionID, ports.UIActionNavigationFailed, ports.UIActionResult{})
75+}
76+
52 // releaseForeground retires exactly the binding installed for generation.77 // releaseForeground retires exactly the binding installed for generation.
53 // The supervisor owns the presentation fence after the foreground drains.78 // The supervisor owns the presentation fence after the foreground drains.
54 func (u *UI) releaseForeground(generation uint64) {79 func (u *UI) releaseForeground(generation uint64) {

Keyboard shortcuts

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

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

Branches 12

Tags 11

↑↓ moveenter openesc close