fix(client): present Connecting instead of the picker while the initial navigation is pending
- commit
- d1a05e5ba0712de57cb593a2328799641936f09f
- author
- brice · · 2026-09-24 04:09 UTC
- parent
- c549dd3
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +87 -15
M internal/app/broker_client_test.go +12 -5
| @@ -737,11 +737,16 @@ func TestTerminalCompositionRefusedTargetOpensNothingAndKeepsThePicker(t *testin | |||
| 737 | 737 | t.Fatal("the refused target reported no failure") | |
| 738 | 738 | } | |
| 739 | 739 | require.Empty(t, run.service.openedRequests(), "a refused target never opens a stream") | |
| 740 | + | // The pending navigation presents Connecting, never the picker | |
| 741 | + | // first; the refusal then returns to the picker and never attaches. | |
| 742 | + | last := client.PresentConnecting | |
| 740 | 743 | for { | |
| 741 | 744 | select { | |
| 742 | 745 | case state := <-run.states: | |
| 743 | - | require.Equal(t, client.PresentPicker, state.Presentation, "a refusal keeps the picker presentation") | |
| 746 | + | require.NotEqual(t, client.PresentAttached, state.Presentation, "a refusal never attaches") | |
| 747 | + | last = state.Presentation | |
| 744 | 748 | case <-time.After(100 * time.Millisecond): | |
| 749 | + | require.Equal(t, client.PresentPicker, last, "a refusal ends on the picker") | |
| 745 | 750 | return | |
| 746 | 751 | } | |
| 747 | 752 | } | |
| @@ -1009,11 +1014,13 @@ func TestTerminalCompositionExactAttachReportsMissingLocalInventory(t *testing.T | |||
| 1009 | 1014 | t.Fatal("the exact attach reported no failure") | |
| 1010 | 1015 | } | |
| 1011 | 1016 | // The refused navigation never writes a session frame and never attaches: | |
| 1012 | - | // only the Picker presentation may be published, never a Connecting or | |
| 1013 | - | // Attached session presentation. | |
| 1014 | - | for _, status := range terminal.publishes() { | |
| 1015 | - | require.Equal(t, ports.UIStatusPicker, status, "a refused navigation publishes only Picker") | |
| 1017 | + | // it presents Connecting while pending and ends on the picker. | |
| 1018 | + | publishes := terminal.publishes() | |
| 1019 | + | for _, status := range publishes { | |
| 1020 | + | require.NotEqual(t, ports.UIStatusAttached, status, "a refused navigation never attaches") | |
| 1016 | 1021 | } | |
| 1022 | + | require.NotEmpty(t, publishes) | |
| 1023 | + | require.Equal(t, ports.UIStatusPicker, publishes[len(publishes)-1], "a refused navigation ends on the picker") | |
| 1017 | 1024 | cancel() | |
| 1018 | 1025 | require.NoError(t, ignoreContextCancellation(<-done)) | |
| 1019 | 1026 | } | |
| @@ -737,11 +737,16 @@ func TestTerminalCompositionRefusedTargetOpensNothingAndKeepsThePicker(t *testin | |||||
| 737 | t.Fatal("the refused target reported no failure") | 737 | t.Fatal("the refused target reported no failure") | ||
| 738 | } | 738 | } | ||
| 739 | require.Empty(t, run.service.openedRequests(), "a refused target never opens a stream") | 739 | require.Empty(t, run.service.openedRequests(), "a refused target never opens a stream") | ||
| 740 | + | // The pending navigation presents Connecting, never the picker | |||
| 741 | + | // first; the refusal then returns to the picker and never attaches. | |||
| 742 | + | last := client.PresentConnecting | |||
| 740 | for { | 743 | for { | ||
| 741 | select { | 744 | select { | ||
| 742 | case state := <-run.states: | 745 | case state := <-run.states: | ||
| 743 | - | require.Equal(t, client.PresentPicker, state.Presentation, "a refusal keeps the picker presentation") | 746 | + | require.NotEqual(t, client.PresentAttached, state.Presentation, "a refusal never attaches") |
| 747 | + | last = state.Presentation | |||
| 744 | case <-time.After(100 * time.Millisecond): | 748 | case <-time.After(100 * time.Millisecond): | ||
| 749 | + | require.Equal(t, client.PresentPicker, last, "a refusal ends on the picker") | |||
| 745 | return | 750 | return | ||
| 746 | } | 751 | } | ||
| 747 | } | 752 | } | ||
| @@ -1009,11 +1014,13 @@ func TestTerminalCompositionExactAttachReportsMissingLocalInventory(t *testing.T | |||||
| 1009 | t.Fatal("the exact attach reported no failure") | 1014 | t.Fatal("the exact attach reported no failure") | ||
| 1010 | } | 1015 | } | ||
| 1011 | // The refused navigation never writes a session frame and never attaches: | 1016 | // The refused navigation never writes a session frame and never attaches: | ||
| 1012 | - | // only the Picker presentation may be published, never a Connecting or | 1017 | + | // it presents Connecting while pending and ends on the picker. |
| 1013 | - | // Attached session presentation. | 1018 | + | publishes := terminal.publishes() |
| 1014 | - | for _, status := range terminal.publishes() { | 1019 | + | for _, status := range publishes { |
| 1015 | - | require.Equal(t, ports.UIStatusPicker, status, "a refused navigation publishes only Picker") | 1020 | + | require.NotEqual(t, ports.UIStatusAttached, status, "a refused navigation never attaches") |
| 1016 | } | 1021 | } | ||
| 1022 | + | require.NotEmpty(t, publishes) | |||
| 1023 | + | require.Equal(t, ports.UIStatusPicker, publishes[len(publishes)-1], "a refused navigation ends on the picker") | |||
| 1017 | cancel() | 1024 | cancel() | ||
| 1018 | require.NoError(t, ignoreContextCancellation(<-done)) | 1025 | require.NoError(t, ignoreContextCancellation(<-done)) | ||
| 1019 | } | 1026 | } | ||
M internal/app/web_daemon_broker_test.go +11 -9
| @@ -500,16 +500,17 @@ func TestWebGatewayTabsShareOneBrokerWithIndependentStreamsAndServices(t *testin | |||
| 500 | 500 | require.Equal(t, carriageAfterFirst, fixture.broker.physicalCount(), | |
| 501 | 501 | "the second tab opened another daemonmux carriage instead of reusing the pooled one") | |
| 502 | 502 | ||
| 503 | - | // The identical state machine: each browser run presents Picker, Connecting, | |
| 504 | - | // and Attached exactly like every other frontend. Consult the durable | |
| 505 | - | // observation barrier: the two Attached notifications above were consumed | |
| 506 | - | // while synchronizing startup and must still count here. | |
| 503 | + | // The identical state machine: each browser run's initial creation presents | |
| 504 | + | // Connecting then Attached exactly like every other frontend, never a | |
| 505 | + | // picker flash first. Consult the durable observation barrier: the two | |
| 506 | + | // Attached notifications above were consumed while synchronizing startup | |
| 507 | + | // and must still count here. | |
| 507 | 508 | require.Eventually(t, func() bool { | |
| 508 | 509 | presentations := fixture.observedPresentations() | |
| 509 | - | return presentations[client.PresentPicker] >= 2 && | |
| 510 | - | presentations[client.PresentConnecting] >= 2 && | |
| 510 | + | return presentations[client.PresentConnecting] >= 2 && | |
| 511 | 511 | presentations[client.PresentAttached] >= 2 | |
| 512 | - | }, brokerTestWait, 5*time.Millisecond, "both browser runs must present picker, connecting, and attached; observed %v", fixture.observedPresentations()) | |
| 512 | + | }, brokerTestWait, 5*time.Millisecond, "both browser runs must present connecting and attached; observed %v", fixture.observedPresentations()) | |
| 513 | + | require.Zero(t, fixture.observedPresentations()[client.PresentPicker], "an initial creation never flashes the picker") | |
| 513 | 514 | ||
| 514 | 515 | // Real content and real actions, and each tab renders only its own session. | |
| 515 | 516 | first.submit(t, "PING-FIRST") | |
| @@ -674,12 +675,13 @@ func TestWebGatewayTerminalIsNotDialedBeforeConnect(t *testing.T) { | |||
| 674 | 675 | ||
| 675 | 676 | fixture := startWebGatewayFixtureOver(t, offlineClientFixture{}, newProductionBrokerConnector) | |
| 676 | 677 | tab := fixture.openTab(t) | |
| 677 | - | fixture.awaitPresentation(t, client.PresentPicker) | |
| 678 | + | // The tab's initial creation is pending, so it presents Connecting. | |
| 679 | + | fixture.awaitPresentation(t, client.PresentConnecting) | |
| 678 | 680 | // Construction performs no I/O: the gateway reaches the broker only through | |
| 679 | 681 | // Connect, and an unreachable broker is an ordinary attempt failure. | |
| 680 | 682 | require.Eventually(t, func() bool { return connects.Load() >= 1 }, brokerTestWait, 5*time.Millisecond, | |
| 681 | 683 | "the gateway must reach the broker only through Connect") | |
| 682 | - | // A broker that is never reachable keeps the tab usable in the picker. | |
| 684 | + | // A broker that is never reachable keeps the tab alive and repainting. | |
| 683 | 685 | before := tab.frameCount() | |
| 684 | 686 | tab.awaitMoreFrames(t, before+1) | |
| 685 | 687 | select { | |
| @@ -500,16 +500,17 @@ func TestWebGatewayTabsShareOneBrokerWithIndependentStreamsAndServices(t *testin | |||||
| 500 | require.Equal(t, carriageAfterFirst, fixture.broker.physicalCount(), | 500 | require.Equal(t, carriageAfterFirst, fixture.broker.physicalCount(), | ||
| 501 | "the second tab opened another daemonmux carriage instead of reusing the pooled one") | 501 | "the second tab opened another daemonmux carriage instead of reusing the pooled one") | ||
| 502 | 502 | ||||
| 503 | - | // The identical state machine: each browser run presents Picker, Connecting, | 503 | + | // The identical state machine: each browser run's initial creation presents |
| 504 | - | // and Attached exactly like every other frontend. Consult the durable | 504 | + | // Connecting then Attached exactly like every other frontend, never a |
| 505 | - | // observation barrier: the two Attached notifications above were consumed | 505 | + | // picker flash first. Consult the durable observation barrier: the two |
| 506 | - | // while synchronizing startup and must still count here. | 506 | + | // Attached notifications above were consumed while synchronizing startup |
| 507 | + | // and must still count here. | |||
| 507 | require.Eventually(t, func() bool { | 508 | require.Eventually(t, func() bool { | ||
| 508 | presentations := fixture.observedPresentations() | 509 | presentations := fixture.observedPresentations() | ||
| 509 | - | return presentations[client.PresentPicker] >= 2 && | 510 | + | return presentations[client.PresentConnecting] >= 2 && |
| 510 | - | presentations[client.PresentConnecting] >= 2 && | |||
| 511 | presentations[client.PresentAttached] >= 2 | 511 | presentations[client.PresentAttached] >= 2 | ||
| 512 | - | }, brokerTestWait, 5*time.Millisecond, "both browser runs must present picker, connecting, and attached; observed %v", fixture.observedPresentations()) | 512 | + | }, brokerTestWait, 5*time.Millisecond, "both browser runs must present connecting and attached; observed %v", fixture.observedPresentations()) |
| 513 | + | require.Zero(t, fixture.observedPresentations()[client.PresentPicker], "an initial creation never flashes the picker") | |||
| 513 | 514 | ||||
| 514 | // Real content and real actions, and each tab renders only its own session. | 515 | // Real content and real actions, and each tab renders only its own session. | ||
| 515 | first.submit(t, "PING-FIRST") | 516 | first.submit(t, "PING-FIRST") | ||
| @@ -674,12 +675,13 @@ func TestWebGatewayTerminalIsNotDialedBeforeConnect(t *testing.T) { | |||||
| 674 | 675 | ||||
| 675 | fixture := startWebGatewayFixtureOver(t, offlineClientFixture{}, newProductionBrokerConnector) | 676 | fixture := startWebGatewayFixtureOver(t, offlineClientFixture{}, newProductionBrokerConnector) | ||
| 676 | tab := fixture.openTab(t) | 677 | tab := fixture.openTab(t) | ||
| 677 | - | fixture.awaitPresentation(t, client.PresentPicker) | 678 | + | // The tab's initial creation is pending, so it presents Connecting. |
| 679 | + | fixture.awaitPresentation(t, client.PresentConnecting) | |||
| 678 | // Construction performs no I/O: the gateway reaches the broker only through | 680 | // Construction performs no I/O: the gateway reaches the broker only through | ||
| 679 | // Connect, and an unreachable broker is an ordinary attempt failure. | 681 | // Connect, and an unreachable broker is an ordinary attempt failure. | ||
| 680 | require.Eventually(t, func() bool { return connects.Load() >= 1 }, brokerTestWait, 5*time.Millisecond, | 682 | require.Eventually(t, func() bool { return connects.Load() >= 1 }, brokerTestWait, 5*time.Millisecond, | ||
| 681 | "the gateway must reach the broker only through Connect") | 683 | "the gateway must reach the broker only through Connect") | ||
| 682 | - | // A broker that is never reachable keeps the tab usable in the picker. | 684 | + | // A broker that is never reachable keeps the tab alive and repainting. |
| 683 | before := tab.frameCount() | 685 | before := tab.frameCount() | ||
| 684 | tab.awaitMoreFrames(t, before+1) | 686 | tab.awaitMoreFrames(t, before+1) | ||
| 685 | select { | 687 | select { | ||
M internal/usecase/client/supervisor.go +34 -1
| @@ -176,6 +176,9 @@ const ( | |||
| 176 | 176 | supervisorAttachEnded | |
| 177 | 177 | // supervisorNonRetryable is a failure that retrying can never fix. | |
| 178 | 178 | supervisorNonRetryable | |
| 179 | + | // supervisorNavigationSettled ends a pending initial navigation's | |
| 180 | + | // Connecting presentation when no attachment took over the terminal. | |
| 181 | + | supervisorNavigationSettled | |
| 179 | 182 | // supervisorTerminal ends the process: terminal EOF, process cancellation, | |
| 180 | 183 | // or a terminal broker failure. | |
| 181 | 184 | supervisorTerminal | |
| @@ -189,6 +192,11 @@ const ( | |||
| 189 | 192 | type supervisorEvent struct { | |
| 190 | 193 | kind supervisorEventKind | |
| 191 | 194 | err error | |
| 195 | + | // navigating marks a supervisorBeginAttempt made while an initial | |
| 196 | + | // navigation is still pending: the user asked for a session, not the | |
| 197 | + | // picker, so the attempt presents Connecting instead of flashing the | |
| 198 | + | // picker until the navigation settles. | |
| 199 | + | navigating bool | |
| 192 | 200 | } | |
| 193 | 201 | ||
| 194 | 202 | // reduceSupervisor is the pure connectivity/presentation reducer. It is | |
| @@ -198,6 +206,9 @@ func reduceSupervisor(state State, event supervisorEvent) State { | |||
| 198 | 206 | switch event.kind { | |
| 199 | 207 | case supervisorBeginAttempt: | |
| 200 | 208 | state.Presentation = PresentPicker | |
| 209 | + | if event.navigating { | |
| 210 | + | state.Presentation = PresentConnecting | |
| 211 | + | } | |
| 201 | 212 | state.Connectivity = ConnectivityConnectingBroker | |
| 202 | 213 | state.Generation++ | |
| 203 | 214 | state.Err = nil | |
| @@ -230,8 +241,17 @@ func reduceSupervisor(state State, event supervisorEvent) State { | |||
| 230 | 241 | state.Presentation = PresentPicker | |
| 231 | 242 | state.Err = event.err | |
| 232 | 243 | case supervisorNonRetryable: | |
| 244 | + | // The run stays on the picker with the visible error; a pending | |
| 245 | + | // initial navigation can no longer present Connecting. | |
| 246 | + | state.Presentation = PresentPicker | |
| 233 | 247 | state.Connectivity = ConnectivityDisconnected | |
| 234 | 248 | state.Err = event.err | |
| 249 | + | case supervisorNavigationSettled: | |
| 250 | + | // An initial navigation that ended without an attachment (a resolved | |
| 251 | + | // picker, or a loss before it could be taken) leaves Connecting. | |
| 252 | + | if state.Presentation == PresentConnecting { | |
| 253 | + | state.Presentation = PresentPicker | |
| 254 | + | } | |
| 235 | 255 | case supervisorOverlayOpened: | |
| 236 | 256 | // Only a committed attachment can host the overlay: a connecting | |
| 237 | 257 | // foreground has not proven its first frame yet. | |
| @@ -493,6 +513,18 @@ func NewSupervisor(cfg SupervisorConfig) (*Supervisor, error) { | |||
| 493 | 513 | return supervisor, nil | |
| 494 | 514 | } | |
| 495 | 515 | ||
| 516 | + | // initialNavigationPending reports whether an initial navigation other than | |
| 517 | + | // the picker is still armed. A resolver may still resolve to the picker; the | |
| 518 | + | // navigation then settles back to it without an attachment. | |
| 519 | + | func (s *Supervisor) initialNavigationPending() bool { | |
| 520 | + | s.mu.Lock() | |
| 521 | + | defer s.mu.Unlock() | |
| 522 | + | if s.navigationConsumed { | |
| 523 | + | return false | |
| 524 | + | } | |
| 525 | + | return s.cfg.ResolveInitialNavigation != nil || s.navigation.Kind != InitialNavigationPicker | |
| 526 | + | } | |
| 527 | + | ||
| 496 | 528 | // State returns the current immutable projection. | |
| 497 | 529 | func (s *Supervisor) State() State { | |
| 498 | 530 | s.mu.Lock() | |
| @@ -551,7 +583,7 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) { | |||
| 551 | 583 | // The generation is claimed before the connector runs, so the attempt is | |
| 552 | 584 | // always attributable to the state that started it and a completion can be | |
| 553 | 585 | // matched against the generation that produced it. | |
| 554 | - | s.transition(supervisorEvent{kind: supervisorBeginAttempt}) | |
| 586 | + | s.transition(supervisorEvent{kind: supervisorBeginAttempt, navigating: s.initialNavigationPending()}) | |
| 555 | 587 | generation := s.State().Generation | |
| 556 | 588 | attemptCtx, cancelAttempt := context.WithCancel(ctx) | |
| 557 | 589 | outcome := make(chan supervisorAttempt, 1) | |
| @@ -626,6 +658,7 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) { | |||
| 626 | 658 | s.transition(supervisorEvent{kind: supervisorTerminal, err: termErr}) | |
| 627 | 659 | return termErr | |
| 628 | 660 | } | |
| 661 | + | s.transition(supervisorEvent{kind: supervisorNavigationSettled}) | |
| 629 | 662 | ||
| 630 | 663 | // Ready phase: fold publications, admit committed attachments one at a | |
| 631 | 664 | // time, and return to the picker after each. A committed attachment | |
| @@ -176,6 +176,9 @@ const ( | |||||
| 176 | supervisorAttachEnded | 176 | supervisorAttachEnded | ||
| 177 | // supervisorNonRetryable is a failure that retrying can never fix. | 177 | // supervisorNonRetryable is a failure that retrying can never fix. | ||
| 178 | supervisorNonRetryable | 178 | supervisorNonRetryable | ||
| 179 | + | // supervisorNavigationSettled ends a pending initial navigation's | |||
| 180 | + | // Connecting presentation when no attachment took over the terminal. | |||
| 181 | + | supervisorNavigationSettled | |||
| 179 | // supervisorTerminal ends the process: terminal EOF, process cancellation, | 182 | // supervisorTerminal ends the process: terminal EOF, process cancellation, | ||
| 180 | // or a terminal broker failure. | 183 | // or a terminal broker failure. | ||
| 181 | supervisorTerminal | 184 | supervisorTerminal | ||
| @@ -189,6 +192,11 @@ const ( | |||||
| 189 | type supervisorEvent struct { | 192 | type supervisorEvent struct { | ||
| 190 | kind supervisorEventKind | 193 | kind supervisorEventKind | ||
| 191 | err error | 194 | err error | ||
| 195 | + | // navigating marks a supervisorBeginAttempt made while an initial | |||
| 196 | + | // navigation is still pending: the user asked for a session, not the | |||
| 197 | + | // picker, so the attempt presents Connecting instead of flashing the | |||
| 198 | + | // picker until the navigation settles. | |||
| 199 | + | navigating bool | |||
| 192 | } | 200 | } | ||
| 193 | 201 | ||||
| 194 | // reduceSupervisor is the pure connectivity/presentation reducer. It is | 202 | // reduceSupervisor is the pure connectivity/presentation reducer. It is | ||
| @@ -198,6 +206,9 @@ func reduceSupervisor(state State, event supervisorEvent) State { | |||||
| 198 | switch event.kind { | 206 | switch event.kind { | ||
| 199 | case supervisorBeginAttempt: | 207 | case supervisorBeginAttempt: | ||
| 200 | state.Presentation = PresentPicker | 208 | state.Presentation = PresentPicker | ||
| 209 | + | if event.navigating { | |||
| 210 | + | state.Presentation = PresentConnecting | |||
| 211 | + | } | |||
| 201 | state.Connectivity = ConnectivityConnectingBroker | 212 | state.Connectivity = ConnectivityConnectingBroker | ||
| 202 | state.Generation++ | 213 | state.Generation++ | ||
| 203 | state.Err = nil | 214 | state.Err = nil | ||
| @@ -230,8 +241,17 @@ func reduceSupervisor(state State, event supervisorEvent) State { | |||||
| 230 | state.Presentation = PresentPicker | 241 | state.Presentation = PresentPicker | ||
| 231 | state.Err = event.err | 242 | state.Err = event.err | ||
| 232 | case supervisorNonRetryable: | 243 | case supervisorNonRetryable: | ||
| 244 | + | // The run stays on the picker with the visible error; a pending | |||
| 245 | + | // initial navigation can no longer present Connecting. | |||
| 246 | + | state.Presentation = PresentPicker | |||
| 233 | state.Connectivity = ConnectivityDisconnected | 247 | state.Connectivity = ConnectivityDisconnected | ||
| 234 | state.Err = event.err | 248 | state.Err = event.err | ||
| 249 | + | case supervisorNavigationSettled: | |||
| 250 | + | // An initial navigation that ended without an attachment (a resolved | |||
| 251 | + | // picker, or a loss before it could be taken) leaves Connecting. | |||
| 252 | + | if state.Presentation == PresentConnecting { | |||
| 253 | + | state.Presentation = PresentPicker | |||
| 254 | + | } | |||
| 235 | case supervisorOverlayOpened: | 255 | case supervisorOverlayOpened: | ||
| 236 | // Only a committed attachment can host the overlay: a connecting | 256 | // Only a committed attachment can host the overlay: a connecting | ||
| 237 | // foreground has not proven its first frame yet. | 257 | // foreground has not proven its first frame yet. | ||
| @@ -493,6 +513,18 @@ func NewSupervisor(cfg SupervisorConfig) (*Supervisor, error) { | |||||
| 493 | return supervisor, nil | 513 | return supervisor, nil | ||
| 494 | } | 514 | } | ||
| 495 | 515 | ||||
| 516 | + | // initialNavigationPending reports whether an initial navigation other than | |||
| 517 | + | // the picker is still armed. A resolver may still resolve to the picker; the | |||
| 518 | + | // navigation then settles back to it without an attachment. | |||
| 519 | + | func (s *Supervisor) initialNavigationPending() bool { | |||
| 520 | + | s.mu.Lock() | |||
| 521 | + | defer s.mu.Unlock() | |||
| 522 | + | if s.navigationConsumed { | |||
| 523 | + | return false | |||
| 524 | + | } | |||
| 525 | + | return s.cfg.ResolveInitialNavigation != nil || s.navigation.Kind != InitialNavigationPicker | |||
| 526 | + | } | |||
| 527 | + | ||||
| 496 | // State returns the current immutable projection. | 528 | // State returns the current immutable projection. | ||
| 497 | func (s *Supervisor) State() State { | 529 | func (s *Supervisor) State() State { | ||
| 498 | s.mu.Lock() | 530 | s.mu.Lock() | ||
| @@ -551,7 +583,7 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) { | |||||
| 551 | // The generation is claimed before the connector runs, so the attempt is | 583 | // The generation is claimed before the connector runs, so the attempt is | ||
| 552 | // always attributable to the state that started it and a completion can be | 584 | // always attributable to the state that started it and a completion can be | ||
| 553 | // matched against the generation that produced it. | 585 | // matched against the generation that produced it. | ||
| 554 | - | s.transition(supervisorEvent{kind: supervisorBeginAttempt}) | 586 | + | s.transition(supervisorEvent{kind: supervisorBeginAttempt, navigating: s.initialNavigationPending()}) |
| 555 | generation := s.State().Generation | 587 | generation := s.State().Generation | ||
| 556 | attemptCtx, cancelAttempt := context.WithCancel(ctx) | 588 | attemptCtx, cancelAttempt := context.WithCancel(ctx) | ||
| 557 | outcome := make(chan supervisorAttempt, 1) | 589 | outcome := make(chan supervisorAttempt, 1) | ||
| @@ -626,6 +658,7 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) { | |||||
| 626 | s.transition(supervisorEvent{kind: supervisorTerminal, err: termErr}) | 658 | s.transition(supervisorEvent{kind: supervisorTerminal, err: termErr}) | ||
| 627 | return termErr | 659 | return termErr | ||
| 628 | } | 660 | } | ||
| 661 | + | s.transition(supervisorEvent{kind: supervisorNavigationSettled}) | |||
| 629 | 662 | ||||
| 630 | // Ready phase: fold publications, admit committed attachments one at a | 663 | // Ready phase: fold publications, admit committed attachments one at a | ||
| 631 | // time, and return to the picker after each. A committed attachment | 664 | // time, and return to the picker after each. A committed attachment | ||
M internal/usecase/client/supervisor_test.go +30 -0
| @@ -568,6 +568,36 @@ func TestSupervisorReducerTransitions(t *testing.T) { | |||
| 568 | 568 | event: supervisorEvent{kind: supervisorNonRetryable, err: incompatible}, | |
| 569 | 569 | want: State{Presentation: PresentPicker, Connectivity: ConnectivityDisconnected, Attempt: 1, Generation: 3, Err: incompatible}, | |
| 570 | 570 | }, | |
| 571 | + | { | |
| 572 | + | name: "begin attempt with a pending navigation presents connecting, not the picker", | |
| 573 | + | state: picker, | |
| 574 | + | event: supervisorEvent{kind: supervisorBeginAttempt, navigating: true}, | |
| 575 | + | want: State{Presentation: PresentConnecting, Connectivity: ConnectivityConnectingBroker, Generation: 1}, | |
| 576 | + | }, | |
| 577 | + | { | |
| 578 | + | name: "retry wait keeps a pending navigation connecting", | |
| 579 | + | state: State{Presentation: PresentConnecting, Connectivity: ConnectivityConnectingBroker, Generation: 2}, | |
| 580 | + | event: supervisorEvent{kind: supervisorTransientFailure, err: transient}, | |
| 581 | + | want: State{Presentation: PresentConnecting, Connectivity: ConnectivityRetryWait, Attempt: 1, Generation: 2, Err: transient}, | |
| 582 | + | }, | |
| 583 | + | { | |
| 584 | + | name: "non-retryable failure during a pending navigation falls back to the picker", | |
| 585 | + | state: State{Presentation: PresentConnecting, Connectivity: ConnectivityConnectingBroker, Generation: 3}, | |
| 586 | + | event: supervisorEvent{kind: supervisorNonRetryable, err: incompatible}, | |
| 587 | + | want: State{Presentation: PresentPicker, Connectivity: ConnectivityDisconnected, Generation: 3, Err: incompatible}, | |
| 588 | + | }, | |
| 589 | + | { | |
| 590 | + | name: "a navigation that settles without attaching returns to the picker", | |
| 591 | + | state: State{Presentation: PresentConnecting, Connectivity: ConnectivityReady, Generation: 4}, | |
| 592 | + | event: supervisorEvent{kind: supervisorNavigationSettled}, | |
| 593 | + | want: State{Presentation: PresentPicker, Connectivity: ConnectivityReady, Generation: 4}, | |
| 594 | + | }, | |
| 595 | + | { | |
| 596 | + | name: "navigation settled leaves an unrelated presentation alone", | |
| 597 | + | state: State{Presentation: PresentAttached, Connectivity: ConnectivityReady, Generation: 4}, | |
| 598 | + | event: supervisorEvent{kind: supervisorNavigationSettled}, | |
| 599 | + | want: State{Presentation: PresentAttached, Connectivity: ConnectivityReady, Generation: 4}, | |
| 600 | + | }, | |
| 571 | 601 | { | |
| 572 | 602 | name: "terminal failure terminates", | |
| 573 | 603 | state: State{Presentation: PresentPicker, Connectivity: ConnectivityReady, Generation: 7}, | |
| @@ -568,6 +568,36 @@ func TestSupervisorReducerTransitions(t *testing.T) { | |||||
| 568 | event: supervisorEvent{kind: supervisorNonRetryable, err: incompatible}, | 568 | event: supervisorEvent{kind: supervisorNonRetryable, err: incompatible}, | ||
| 569 | want: State{Presentation: PresentPicker, Connectivity: ConnectivityDisconnected, Attempt: 1, Generation: 3, Err: incompatible}, | 569 | want: State{Presentation: PresentPicker, Connectivity: ConnectivityDisconnected, Attempt: 1, Generation: 3, Err: incompatible}, | ||
| 570 | }, | 570 | }, | ||
| 571 | + | { | |||
| 572 | + | name: "begin attempt with a pending navigation presents connecting, not the picker", | |||
| 573 | + | state: picker, | |||
| 574 | + | event: supervisorEvent{kind: supervisorBeginAttempt, navigating: true}, | |||
| 575 | + | want: State{Presentation: PresentConnecting, Connectivity: ConnectivityConnectingBroker, Generation: 1}, | |||
| 576 | + | }, | |||
| 577 | + | { | |||
| 578 | + | name: "retry wait keeps a pending navigation connecting", | |||
| 579 | + | state: State{Presentation: PresentConnecting, Connectivity: ConnectivityConnectingBroker, Generation: 2}, | |||
| 580 | + | event: supervisorEvent{kind: supervisorTransientFailure, err: transient}, | |||
| 581 | + | want: State{Presentation: PresentConnecting, Connectivity: ConnectivityRetryWait, Attempt: 1, Generation: 2, Err: transient}, | |||
| 582 | + | }, | |||
| 583 | + | { | |||
| 584 | + | name: "non-retryable failure during a pending navigation falls back to the picker", | |||
| 585 | + | state: State{Presentation: PresentConnecting, Connectivity: ConnectivityConnectingBroker, Generation: 3}, | |||
| 586 | + | event: supervisorEvent{kind: supervisorNonRetryable, err: incompatible}, | |||
| 587 | + | want: State{Presentation: PresentPicker, Connectivity: ConnectivityDisconnected, Generation: 3, Err: incompatible}, | |||
| 588 | + | }, | |||
| 589 | + | { | |||
| 590 | + | name: "a navigation that settles without attaching returns to the picker", | |||
| 591 | + | state: State{Presentation: PresentConnecting, Connectivity: ConnectivityReady, Generation: 4}, | |||
| 592 | + | event: supervisorEvent{kind: supervisorNavigationSettled}, | |||
| 593 | + | want: State{Presentation: PresentPicker, Connectivity: ConnectivityReady, Generation: 4}, | |||
| 594 | + | }, | |||
| 595 | + | { | |||
| 596 | + | name: "navigation settled leaves an unrelated presentation alone", | |||
| 597 | + | state: State{Presentation: PresentAttached, Connectivity: ConnectivityReady, Generation: 4}, | |||
| 598 | + | event: supervisorEvent{kind: supervisorNavigationSettled}, | |||
| 599 | + | want: State{Presentation: PresentAttached, Connectivity: ConnectivityReady, Generation: 4}, | |||
| 600 | + | }, | |||
| 571 | { | 601 | { | ||
| 572 | name: "terminal failure terminates", | 602 | name: "terminal failure terminates", | ||
| 573 | state: State{Presentation: PresentPicker, Connectivity: ConnectivityReady, Generation: 7}, | 603 | state: State{Presentation: PresentPicker, Connectivity: ConnectivityReady, Generation: 7}, | ||