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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 302 | 302 | navigationReplies = overlay.navigationReplies() | |
| 303 | 303 | } | |
| 304 | 304 | var samePeerRequests uint64 | |
| 305 | + | var samePeerSwitch *samePeerSwitchPending | |
| 306 | + | samePeerUI := attachmentSamePeerUI(fg) | |
| 305 | 307 | picker := &attachmentMovePicker{worker: w, fg: fg, overlay: overlay, stream: stream, size: w.cfg.Geometry.Size, move: newMovePickerOverlay(w.cfg.TrueColor)} | |
| 306 | 308 | defer picker.stopEscape() | |
| 307 | 309 | input := newAttachmentInput(ctx, w, fg, stream, picker) | |
| @@ -369,6 +371,14 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||
| 369 | 371 | } | |
| 370 | 372 | state = next | |
| 371 | 373 | 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 | 382 | attachmentNoteCommitted(fg, state.context) | |
| 373 | 383 | if err := fg.Output(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusAttached), typed.Data); err != nil { | |
| 374 | 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 | 423 | if request.Validate() != nil { | |
| 414 | 424 | continue | |
| 415 | 425 | } | |
| 426 | + | if samePeerUI != nil { | |
| 427 | + | samePeerUI.uiFollowSamePeer(typed.CauseActionID) | |
| 428 | + | } | |
| 429 | + | samePeerSwitch = &samePeerSwitchPending{requestID: request.RequestID, target: request.Target} | |
| 416 | 430 | if err := w.send(ctx, fg, stream, request); err != nil { | |
| 417 | 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 | 435 | if overlay != nil { | |
| 422 | 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 | 446 | case protocol.RouteNavigationAction, protocol.RouteCreateSessionAction: | |
| 425 | 447 | // The daemon asks the client to navigate: the supervisor | |
| 426 | 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 | 581 | // pumpAttachmentInput forwards each authorized delivery to the attached loop. | |
| 539 | 582 | // The loop decides whether an overlay owns it or it becomes session Input, and | |
| 540 | 583 | // 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 uint64 | 304 | 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 = next | 372 | state = next | ||
| 371 | outputResetRequested = false | 373 | 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 | continue | 424 | 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 supervisor | 447 | // 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, and | 582 | // 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) { | |||
| 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 | 1134 | // Stream is the supervisor-admitted logical stream this worker owns. | |
| 1113 | 1135 | func (f *attachmentForeground) Stream() ports.BrokerLogicalConnection { return f.stream } | |
| 1114 | 1136 | ||
| @@ -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) { | |||
| 262 | 262 | require.Equal(t, "example", published.ActiveEntry.HostLabel) | |
| 263 | 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 | + | } | |
| @@ -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 { | |||
| 49 | 49 | 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 | 77 | // releaseForeground retires exactly the binding installed for generation. | |
| 53 | 78 | // The supervisor owns the presentation fence after the foreground drains. | |
| 54 | 79 | func (u *UI) releaseForeground(generation uint64) { | |
| @@ -49,6 +49,31 @@ func (u *UI) follow(generation, actionID uint64) bool { | |||||
| 49 | return true | 49 | 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) { | ||