]> git.feebdaed.xyz Git - 0xmirror/grpc.git/commitdiff
[pick_first] go CONNECTING when selected subchannel goes CONNECTING or TF (#41029)
authorMark D. Roth <roth@google.com>
Fri, 5 Dec 2025 20:24:49 +0000 (12:24 -0800)
committerCopybara-Service <copybara-worker@google.com>
Fri, 5 Dec 2025 20:27:39 +0000 (12:27 -0800)
Needed as part of gRFC A105 (https://github.com/grpc/proposal/pull/516).

Currently, when the selected subchannel leaves READY state, the only possible state it can move to is IDLE, and pick_first handles that by itself going IDLE.  However, as part of A105, we are going to introduce the possibility of the subchannel going from READY to either CONNECTING or TRANSIENT_FAILURE, and in those two cases we want pick_first to go back into CONNECTING and start a new happy eyeballs pass.  This PR introduces an experiment that adds that behavior.

While I was at it, I noticed an existing misfeature.  There are two cases where pick_first will go IDLE, which is done by calling [`GoIdle()`](https://github.com/grpc/grpc/blob/24b25a0baa72a658cc37d1db28f77513a9670ea2/src/core/load_balancing/pick_first/pick_first.cc#L610):
1. The case mentioned above, where the selected subchannel goes from READY to IDLE (`GoIdle()` is called from [`SubchannelState::OnConnectivityStateChange()`](https://github.com/grpc/grpc/blob/24b25a0baa72a658cc37d1db28f77513a9670ea2/src/core/load_balancing/pick_first/pick_first.cc#L784)).
2. The case where pick_first already has a selected subchannel and receives a new address list, but none of the subchannels in the new list report READY.  In this case, pick_first knows that the currently selected subchannel is for an address that is not present in the new address list, so it unrefs the selected subchannel and goes IDLE (`GoIdle()` is called from [`SubchannelData::OnConnectivityStateChange()`](https://github.com/grpc/grpc/blob/24b25a0baa72a658cc37d1db28f77513a9670ea2/src/core/load_balancing/pick_first/pick_first.cc#L859)).

The code in `GoIdle()` currently requests a re-resolution, which is the right behavior for case 1.  However, it doesn't really make sense to do this for case 2, since we have just received a fresh resolver update in that case.  Therefore, as part of this experiment, I am moving the code that triggers the re-resolution out of `GoIdle()` and directly into `SubchannelState::OnConnectivityStateChange()`, where it will occur only for case 1.

Closes #41029

COPYBARA_INTEGRATE_REVIEW=https://github.com/grpc/grpc/pull/41029 from markdroth:pick_first_ready_to_connecting fdb6ef68e3a73e0035520149b72a1d21775354c3
PiperOrigin-RevId: 840830927

bazel/experiments.bzl
src/core/lib/experiments/experiments.cc
src/core/lib/experiments/experiments.h
src/core/lib/experiments/experiments.yaml
src/core/load_balancing/pick_first/pick_first.cc
test/core/load_balancing/lb_policy_test_lib.h
test/core/load_balancing/pick_first_test.cc

index fcd7bf28a09422c6f33d34578cc4f7c9320da154..ed8b42bd788f0e28960b1d2b261e9182d742dbbe 100644 (file)
@@ -43,6 +43,7 @@ EXPERIMENT_ENABLES = {
     "multiping": "multiping",
     "otel_export_telemetry_domains": "otel_export_telemetry_domains",
     "pick_first_ignore_empty_updates": "pick_first_ignore_empty_updates",
+    "pick_first_ready_to_connecting": "pick_first_ready_to_connecting",
     "pipelined_read_secure_endpoint": "event_engine_client,event_engine_listener,event_engine_secure_endpoint,pipelined_read_secure_endpoint",
     "pollset_alternative": "event_engine_client,event_engine_listener,pollset_alternative",
     "prioritize_finished_requests": "prioritize_finished_requests",
@@ -109,6 +110,7 @@ EXPERIMENTS = {
                 "subchannel_wrapper_cleanup_on_orphan",
             ],
             "cpp_lb_end2end_test": [
+                "pick_first_ready_to_connecting",
                 "rr_wrr_connect_from_random_index",
                 "transport_state_watcher",
             ],
@@ -128,6 +130,7 @@ EXPERIMENTS = {
                 "tcp_rcv_lowat",
             ],
             "lb_unit_test": [
+                "pick_first_ready_to_connecting",
                 "rr_wrr_connect_from_random_index",
             ],
             "minimal_stack_test": [
@@ -214,6 +217,7 @@ EXPERIMENTS = {
                 "subchannel_wrapper_cleanup_on_orphan",
             ],
             "cpp_lb_end2end_test": [
+                "pick_first_ready_to_connecting",
                 "rr_wrr_connect_from_random_index",
                 "transport_state_watcher",
             ],
@@ -233,6 +237,7 @@ EXPERIMENTS = {
                 "tcp_rcv_lowat",
             ],
             "lb_unit_test": [
+                "pick_first_ready_to_connecting",
                 "rr_wrr_connect_from_random_index",
             ],
             "minimal_stack_test": [
@@ -319,6 +324,7 @@ EXPERIMENTS = {
                 "subchannel_wrapper_cleanup_on_orphan",
             ],
             "cpp_lb_end2end_test": [
+                "pick_first_ready_to_connecting",
                 "rr_wrr_connect_from_random_index",
                 "transport_state_watcher",
             ],
@@ -338,6 +344,7 @@ EXPERIMENTS = {
                 "tcp_rcv_lowat",
             ],
             "lb_unit_test": [
+                "pick_first_ready_to_connecting",
                 "rr_wrr_connect_from_random_index",
             ],
             "minimal_stack_test": [
index 98df73f71376aee69f07e7fe2c1a16439d355396..45227068976cb2ee44e87d7d46148cbd1755189f 100644 (file)
@@ -119,6 +119,10 @@ const char* const additional_constraints_otel_export_telemetry_domains = "{}";
 const char* const description_pick_first_ignore_empty_updates =
     "Ignore empty resolutions in pick_first";
 const char* const additional_constraints_pick_first_ignore_empty_updates = "{}";
+const char* const description_pick_first_ready_to_connecting =
+    "When the subchannel goes from READY to CONNECTING or TRANSIENT_FAILURE, "
+    "pick_first goes to CONNECTING and starts a new Happy Eyeballs pass.";
+const char* const additional_constraints_pick_first_ready_to_connecting = "{}";
 const char* const description_pipelined_read_secure_endpoint =
     "Enable pipelined reads for EventEngine secure endpoints";
 const char* const additional_constraints_pipelined_read_secure_endpoint = "{}";
@@ -301,6 +305,10 @@ const ExperimentMetadata g_experiment_metadata[] = {
      description_pick_first_ignore_empty_updates,
      additional_constraints_pick_first_ignore_empty_updates, nullptr, 0, false,
      true},
+    {"pick_first_ready_to_connecting",
+     description_pick_first_ready_to_connecting,
+     additional_constraints_pick_first_ready_to_connecting, nullptr, 0, false,
+     true},
     {"pipelined_read_secure_endpoint",
      description_pipelined_read_secure_endpoint,
      additional_constraints_pipelined_read_secure_endpoint,
@@ -479,6 +487,10 @@ const char* const additional_constraints_otel_export_telemetry_domains = "{}";
 const char* const description_pick_first_ignore_empty_updates =
     "Ignore empty resolutions in pick_first";
 const char* const additional_constraints_pick_first_ignore_empty_updates = "{}";
+const char* const description_pick_first_ready_to_connecting =
+    "When the subchannel goes from READY to CONNECTING or TRANSIENT_FAILURE, "
+    "pick_first goes to CONNECTING and starts a new Happy Eyeballs pass.";
+const char* const additional_constraints_pick_first_ready_to_connecting = "{}";
 const char* const description_pipelined_read_secure_endpoint =
     "Enable pipelined reads for EventEngine secure endpoints";
 const char* const additional_constraints_pipelined_read_secure_endpoint = "{}";
@@ -661,6 +673,10 @@ const ExperimentMetadata g_experiment_metadata[] = {
      description_pick_first_ignore_empty_updates,
      additional_constraints_pick_first_ignore_empty_updates, nullptr, 0, false,
      true},
+    {"pick_first_ready_to_connecting",
+     description_pick_first_ready_to_connecting,
+     additional_constraints_pick_first_ready_to_connecting, nullptr, 0, false,
+     true},
     {"pipelined_read_secure_endpoint",
      description_pipelined_read_secure_endpoint,
      additional_constraints_pipelined_read_secure_endpoint,
@@ -839,6 +855,10 @@ const char* const additional_constraints_otel_export_telemetry_domains = "{}";
 const char* const description_pick_first_ignore_empty_updates =
     "Ignore empty resolutions in pick_first";
 const char* const additional_constraints_pick_first_ignore_empty_updates = "{}";
+const char* const description_pick_first_ready_to_connecting =
+    "When the subchannel goes from READY to CONNECTING or TRANSIENT_FAILURE, "
+    "pick_first goes to CONNECTING and starts a new Happy Eyeballs pass.";
+const char* const additional_constraints_pick_first_ready_to_connecting = "{}";
 const char* const description_pipelined_read_secure_endpoint =
     "Enable pipelined reads for EventEngine secure endpoints";
 const char* const additional_constraints_pipelined_read_secure_endpoint = "{}";
@@ -1021,6 +1041,10 @@ const ExperimentMetadata g_experiment_metadata[] = {
      description_pick_first_ignore_empty_updates,
      additional_constraints_pick_first_ignore_empty_updates, nullptr, 0, false,
      true},
+    {"pick_first_ready_to_connecting",
+     description_pick_first_ready_to_connecting,
+     additional_constraints_pick_first_ready_to_connecting, nullptr, 0, false,
+     true},
     {"pipelined_read_secure_endpoint",
      description_pipelined_read_secure_endpoint,
      additional_constraints_pipelined_read_secure_endpoint,
index 2e7d2ff0e91e062d2c6c8f9207bec04f1871f51e..a8011daf2b6475d3019a0c0b369474ea08cca384 100644 (file)
@@ -95,6 +95,7 @@ inline bool IsMonitoringExperimentEnabled() { return true; }
 inline bool IsMultipingEnabled() { return false; }
 inline bool IsOtelExportTelemetryDomainsEnabled() { return false; }
 inline bool IsPickFirstIgnoreEmptyUpdatesEnabled() { return false; }
+inline bool IsPickFirstReadyToConnectingEnabled() { return false; }
 inline bool IsPipelinedReadSecureEndpointEnabled() { return false; }
 inline bool IsPollsetAlternativeEnabled() { return false; }
 inline bool IsPrioritizeFinishedRequestsEnabled() { return false; }
@@ -158,6 +159,7 @@ inline bool IsMonitoringExperimentEnabled() { return true; }
 inline bool IsMultipingEnabled() { return false; }
 inline bool IsOtelExportTelemetryDomainsEnabled() { return false; }
 inline bool IsPickFirstIgnoreEmptyUpdatesEnabled() { return false; }
+inline bool IsPickFirstReadyToConnectingEnabled() { return false; }
 inline bool IsPipelinedReadSecureEndpointEnabled() { return false; }
 inline bool IsPollsetAlternativeEnabled() { return false; }
 inline bool IsPrioritizeFinishedRequestsEnabled() { return false; }
@@ -221,6 +223,7 @@ inline bool IsMonitoringExperimentEnabled() { return true; }
 inline bool IsMultipingEnabled() { return false; }
 inline bool IsOtelExportTelemetryDomainsEnabled() { return false; }
 inline bool IsPickFirstIgnoreEmptyUpdatesEnabled() { return false; }
+inline bool IsPickFirstReadyToConnectingEnabled() { return false; }
 inline bool IsPipelinedReadSecureEndpointEnabled() { return false; }
 inline bool IsPollsetAlternativeEnabled() { return false; }
 inline bool IsPrioritizeFinishedRequestsEnabled() { return false; }
@@ -274,6 +277,7 @@ enum ExperimentIds {
   kExperimentIdMultiping,
   kExperimentIdOtelExportTelemetryDomains,
   kExperimentIdPickFirstIgnoreEmptyUpdates,
+  kExperimentIdPickFirstReadyToConnecting,
   kExperimentIdPipelinedReadSecureEndpoint,
   kExperimentIdPollsetAlternative,
   kExperimentIdPrioritizeFinishedRequests,
@@ -402,6 +406,10 @@ inline bool IsOtelExportTelemetryDomainsEnabled() {
 inline bool IsPickFirstIgnoreEmptyUpdatesEnabled() {
   return IsExperimentEnabled<kExperimentIdPickFirstIgnoreEmptyUpdates>();
 }
+#define GRPC_EXPERIMENT_IS_INCLUDED_PICK_FIRST_READY_TO_CONNECTING
+inline bool IsPickFirstReadyToConnectingEnabled() {
+  return IsExperimentEnabled<kExperimentIdPickFirstReadyToConnecting>();
+}
 #define GRPC_EXPERIMENT_IS_INCLUDED_PIPELINED_READ_SECURE_ENDPOINT
 inline bool IsPipelinedReadSecureEndpointEnabled() {
   return IsExperimentEnabled<kExperimentIdPipelinedReadSecureEndpoint>();
index 4fd51f56ec416ecd0f0f4b5cfa1049acc0dd0cd1..78da41d8932403a527b67add36973335531c1faa 100644 (file)
   description: Ignore empty resolutions in pick_first
   expiry: 2026/02/02
   owner: ctiller@google.com
+- name: pick_first_ready_to_connecting
+  description:
+    When the subchannel goes from READY to CONNECTING or TRANSIENT_FAILURE,
+    pick_first goes to CONNECTING and starts a new Happy Eyeballs pass.
+  expiry: 2026/02/01
+  owner: roth@google.com
+  test_tags: ["lb_unit_test", "cpp_lb_end2end_test"]
 - name: pipelined_read_secure_endpoint
   description: Enable pipelined reads for EventEngine secure endpoints
   expiry: 2026/03/15
index cb8b5954921027aa413f8fcb72e249708a22f10e..8bb5adbb155838f5d3c0ede82aca560b0155db57 100644 (file)
@@ -390,13 +390,16 @@ class PickFirst final : public LoadBalancingPolicy {
   // Lateset update args.
   UpdateArgs latest_update_args_;
   // The list of subchannels that we're currently trying to connect to.
-  // Will generally be null when selected_ is set, except when we get a
-  // resolver update and need to check initial connectivity states for
-  // the new list to decide whether we keep using the existing
-  // connection or go IDLE.
+  // Will generally be null when selected_ is set, except for two cases:
+  // - When we get a resolver update and need to check initial connectivity
+  //   states for the new list to decide whether we keep using the existing
+  //   connection or go IDLE.
+  // - When the selected subchannel transitions from READY to CONNECTING
+  //   or TRANSIENT_FAILURE (instead of IDLE), in which case we create a
+  //   new subchannel list and start connecting with a Happy Eyeballs pass.
   OrphanablePtr<SubchannelList> subchannel_list_;
   // Selected subchannel.  Will generally be null when subchannel_list_
-  // is non-null, with the exception mentioned above.
+  // is non-null, with the exceptions mentioned above.
   OrphanablePtr<SubchannelList::SubchannelData::SubchannelState> selected_;
   // Health watcher for the selected subchannel.
   SubchannelInterface::ConnectivityStateWatcherInterface* health_watcher_ =
@@ -612,10 +615,12 @@ void PickFirst::GoIdle() {
   UnsetSelectedSubchannel();
   // Drop the current subchannel list, if any.
   subchannel_list_.reset();
-  // Request a re-resolution.
-  // TODO(qianchengz): We may want to request re-resolution in
-  // ExitIdleLocked() instead.
-  channel_control_helper()->RequestReresolution();
+  if (!IsPickFirstReadyToConnectingEnabled()) {
+    // Request a re-resolution.
+    // TODO(roth): We may want to request re-resolution in
+    // ExitIdleLocked() instead.
+    channel_control_helper()->RequestReresolution();
+  }
   // Enter idle.
   UpdateState(GRPC_CHANNEL_IDLE, absl::OkStatus(),
               MakeRefCounted<QueuePicker>(Ref(DEBUG_LOCATION, "QueuePicker")));
@@ -780,8 +785,33 @@ void PickFirst::SubchannelList::SubchannelData::SubchannelState::
   stats_plugins.AddCounter(kMetricDisconnections, 1,
                            {pick_first_->channel_control_helper()->GetTarget()},
                            {});
-  // Report IDLE.
-  pick_first_->GoIdle();
+  if (IsPickFirstReadyToConnectingEnabled()) {
+    // TODO(roth): We may want to request re-resolution in
+    // ExitIdleLocked() instead, at least if we go IDLE below.
+    pick_first_->channel_control_helper()->RequestReresolution();
+  }
+  // If the subchannel went to CONNECTING or TRANSIENT_FAILURE, we go
+  // back to CONNECTING and start a new Happy Eyeballs pass.
+  // Otherwise, go IDLE.
+  if (IsPickFirstReadyToConnectingEnabled() &&
+      (new_state == GRPC_CHANNEL_CONNECTING ||
+       new_state == GRPC_CHANNEL_TRANSIENT_FAILURE)) {
+    pick_first_->UpdateState(GRPC_CHANNEL_CONNECTING, absl::OkStatus(),
+                             MakeRefCounted<QueuePicker>(nullptr));
+    pick_first_->AttemptToConnectUsingLatestUpdateArgsLocked();
+    // Unset the selected subchannel, so that when we see the initial
+    // connectivity state notifications for the subchannels in the new
+    // subchannel list, we don't think it was caused by a resolver
+    // update and go IDLE if none of the subchannels report READY.
+    //
+    // Note that we do this *after* creating the new subchannel list,
+    // which will have taken a new ref to the originally selected
+    // subchannel.  This ensures that we don't destroy and recreate the
+    // subchannel, thus preserving the backoff state inside the subchannel.
+    pick_first_->UnsetSelectedSubchannel();
+  } else {
+    pick_first_->GoIdle();
+  }
 }
 
 //
index 0ad4d6f89938af53d90c3425f0f2b767b271a82e..a10343b170cdb73c7562ba67e8d54d2659f53223 100644 (file)
@@ -141,31 +141,33 @@ class LoadBalancingPolicyTest : public ::testing::Test {
       class WatcherWrapper : public AsyncConnectivityStateWatcherInterface {
        public:
         WatcherWrapper(
-            std::shared_ptr<WorkSerializer> work_serializer,
+            SubchannelState* state,
             std::unique_ptr<
                 SubchannelInterface::ConnectivityStateWatcherInterface>
                 watcher)
-            : AsyncConnectivityStateWatcherInterface(
-                  std::move(work_serializer)),
+            : AsyncConnectivityStateWatcherInterface(state->work_serializer()),
+              state_(state),
               watcher_(std::move(watcher)) {}
 
         WatcherWrapper(
-            std::shared_ptr<WorkSerializer> work_serializer,
+            SubchannelState* state,
             std::shared_ptr<
                 SubchannelInterface::ConnectivityStateWatcherInterface>
                 watcher)
-            : AsyncConnectivityStateWatcherInterface(
-                  std::move(work_serializer)),
+            : AsyncConnectivityStateWatcherInterface(state->work_serializer()),
+              state_(state),
               watcher_(std::move(watcher)) {}
 
         void OnConnectivityStateChange(grpc_connectivity_state new_state,
                                        const absl::Status& status) override {
-          LOG(INFO) << "notifying watcher: state="
-                    << ConnectivityStateName(new_state) << " status=" << status;
+          LOG(INFO) << "notifying watcher for " << state_->address_
+                    << ": state=" << ConnectivityStateName(new_state)
+                    << " status=" << status;
           watcher_->OnConnectivityStateChange(new_state, status);
         }
 
        private:
+        SubchannelState* state_;
         std::shared_ptr<SubchannelInterface::ConnectivityStateWatcherInterface>
             watcher_;
       };
@@ -175,8 +177,8 @@ class LoadBalancingPolicyTest : public ::testing::Test {
               SubchannelInterface::ConnectivityStateWatcherInterface>
               watcher) override {
         auto* watcher_ptr = watcher.get();
-        auto watcher_wrapper = MakeOrphanable<WatcherWrapper>(
-            state_->work_serializer(), std::move(watcher));
+        auto watcher_wrapper =
+            MakeOrphanable<WatcherWrapper>(state_, std::move(watcher));
         watcher_map_[watcher_ptr] = watcher_wrapper.get();
         state_->state_tracker_.AddWatcher(GRPC_CHANNEL_SHUTDOWN,
                                           std::move(watcher_wrapper));
@@ -215,7 +217,7 @@ class LoadBalancingPolicyTest : public ::testing::Test {
           auto connectivity_watcher = health_watcher_->TakeWatcher();
           auto* connectivity_watcher_ptr = connectivity_watcher.get();
           auto watcher_wrapper = MakeOrphanable<WatcherWrapper>(
-              state_->work_serializer(), std::move(connectivity_watcher));
+              state_, std::move(connectivity_watcher));
           health_watcher_wrapper_ = watcher_wrapper.get();
           state_->state_tracker_.AddWatcher(GRPC_CHANNEL_SHUTDOWN,
                                             std::move(watcher_wrapper));
@@ -283,7 +285,9 @@ class LoadBalancingPolicyTest : public ::testing::Test {
               << location.file() << ":" << location.line();
           break;
         case GRPC_CHANNEL_READY:
-          ASSERT_EQ(to_state, GRPC_CHANNEL_IDLE)
+          ASSERT_THAT(to_state, ::testing::AnyOf(
+                                    GRPC_CHANNEL_IDLE, GRPC_CHANNEL_CONNECTING,
+                                    GRPC_CHANNEL_TRANSIENT_FAILURE))
               << ConnectivityStateName(from_state) << "=>"
               << ConnectivityStateName(to_state) << "\n"
               << location.file() << ":" << location.line();
index bdb404ada02a90dad8b7bceab6326687d3b4975f..00a1fa0cafb38a62009a766f98f174578f9c91c8 100644 (file)
@@ -1056,6 +1056,147 @@ TEST_F(PickFirstTest, GoesIdleWhenConnectionFailsThenCanReconnect) {
   }
 }
 
+TEST_F(PickFirstTest, GoesConnectingWhenSelectedSubchannelGoesConnecting) {
+  if (!IsPickFirstReadyToConnectingEnabled()) {
+    GTEST_SKIP() << "requires pick_first_ready_to_connecting experiment";
+  }
+  // Send an update containing two addresses.
+  constexpr std::array<absl::string_view, 2> kAddresses = {
+      "ipv4:127.0.0.1:443", "ipv4:127.0.0.1:444"};
+  absl::Status status = ApplyUpdate(
+      BuildUpdate(kAddresses, MakePickFirstConfig(false)), lb_policy());
+  EXPECT_TRUE(status.ok()) << status;
+  // LB policy should have created a subchannel for both addresses.
+  auto* subchannel = FindSubchannel(kAddresses[0]);
+  ASSERT_NE(subchannel, nullptr);
+  auto* subchannel2 = FindSubchannel(kAddresses[1]);
+  ASSERT_NE(subchannel2, nullptr);
+  // When the LB policy receives the first subchannel's initial connectivity
+  // state notification (IDLE), it will request a connection.
+  EXPECT_TRUE(subchannel->ConnectionRequested());
+  // This causes the subchannel to start to connect, so it reports CONNECTING.
+  subchannel->SetConnectivityState(GRPC_CHANNEL_CONNECTING);
+  // LB policy should have reported CONNECTING state.
+  ExpectConnectingUpdate();
+  // The second subchannel should not be connecting.
+  EXPECT_FALSE(subchannel2->ConnectionRequested());
+  // Subchannel fails to connect.
+  subchannel->SetConnectivityState(GRPC_CHANNEL_TRANSIENT_FAILURE,
+                                   absl::UnavailableError("failed"));
+  // LB policy asks the second subchannel to connect.
+  EXPECT_TRUE(subchannel2->ConnectionRequested());
+  // Second subchannel reports CONNECTING.
+  subchannel2->SetConnectivityState(GRPC_CHANNEL_CONNECTING);
+  // When the subchannel becomes connected, it reports READY.
+  subchannel2->SetConnectivityState(GRPC_CHANNEL_READY);
+  // The LB policy will report CONNECTING some number of times (doesn't
+  // matter how many) and then report READY.
+  auto picker = WaitForConnected();
+  ASSERT_NE(picker, nullptr);
+  // Picker should return the same subchannel repeatedly.
+  for (size_t i = 0; i < 3; ++i) {
+    EXPECT_EQ(ExpectPickComplete(picker.get()), kAddresses[1]);
+  }
+  // First subchannel finishes backoff.
+  subchannel->SetConnectivityState(GRPC_CHANNEL_IDLE);
+  // The selected subchannel goes from READY to CONNECTING.
+  subchannel2->SetConnectivityState(GRPC_CHANNEL_CONNECTING);
+  // We should see a re-resolution request.
+  ExpectReresolutionRequest();
+  // LB policy reports CONNECTNG with a queueing picker.
+  ExpectConnectingUpdate();
+  // LB policy asks the first subchannel to connect.
+  EXPECT_TRUE(subchannel->ConnectionRequested());
+  // First subchannel reports CONNECTING.
+  subchannel->SetConnectivityState(GRPC_CHANNEL_CONNECTING);
+  // Second subchannel gets connected while the first is still trying.
+  subchannel2->SetConnectivityState(GRPC_CHANNEL_READY);
+  // Subchannel succeeds in connecting.
+  subchannel->SetConnectivityState(GRPC_CHANNEL_READY);
+  // LB policy reports READY.
+  picker = WaitForConnected();
+  ASSERT_NE(picker, nullptr);
+  // Picker should return the same subchannel repeatedly.
+  for (size_t i = 0; i < 3; ++i) {
+    EXPECT_EQ(ExpectPickComplete(picker.get()), kAddresses[1]);
+  }
+}
+
+TEST_F(PickFirstTest,
+       GoesConnectingWhenSelectedSubchannelGoesTransientFailure) {
+  if (!IsPickFirstReadyToConnectingEnabled()) {
+    GTEST_SKIP() << "requires pick_first_ready_to_connecting experiment";
+  }
+  // Send an update containing two addresses.
+  constexpr std::array<absl::string_view, 2> kAddresses = {
+      "ipv4:127.0.0.1:443", "ipv4:127.0.0.1:444"};
+  absl::Status status = ApplyUpdate(
+      BuildUpdate(kAddresses, MakePickFirstConfig(false)), lb_policy());
+  EXPECT_TRUE(status.ok()) << status;
+  // LB policy should have created a subchannel for both addresses.
+  auto* subchannel = FindSubchannel(kAddresses[0]);
+  ASSERT_NE(subchannel, nullptr);
+  auto* subchannel2 = FindSubchannel(kAddresses[1]);
+  ASSERT_NE(subchannel2, nullptr);
+  // When the LB policy receives the first subchannel's initial connectivity
+  // state notification (IDLE), it will request a connection.
+  EXPECT_TRUE(subchannel->ConnectionRequested());
+  // This causes the subchannel to start to connect, so it reports CONNECTING.
+  subchannel->SetConnectivityState(GRPC_CHANNEL_CONNECTING);
+  // LB policy should have reported CONNECTING state.
+  ExpectConnectingUpdate();
+  // The second subchannel should not be connecting.
+  EXPECT_FALSE(subchannel2->ConnectionRequested());
+  // Subchannel fails to connect.
+  subchannel->SetConnectivityState(GRPC_CHANNEL_TRANSIENT_FAILURE,
+                                   absl::UnavailableError("failed"));
+  // LB policy asks the second subchannel to connect.
+  EXPECT_TRUE(subchannel2->ConnectionRequested());
+  // Second subchannel reports CONNECTING.
+  subchannel2->SetConnectivityState(GRPC_CHANNEL_CONNECTING);
+  // When the subchannel becomes connected, it reports READY.
+  subchannel2->SetConnectivityState(GRPC_CHANNEL_READY);
+  // The LB policy will report CONNECTING some number of times (doesn't
+  // matter how many) and then report READY.
+  auto picker = WaitForConnected();
+  ASSERT_NE(picker, nullptr);
+  // Picker should return the same subchannel repeatedly.
+  for (size_t i = 0; i < 3; ++i) {
+    EXPECT_EQ(ExpectPickComplete(picker.get()), kAddresses[1]);
+  }
+  // First subchannel finishes backoff.
+  subchannel->SetConnectivityState(GRPC_CHANNEL_IDLE);
+  // The selected subchannel goes from READY to TRANSIENT_FAILURE.
+  subchannel2->SetConnectivityState(GRPC_CHANNEL_TRANSIENT_FAILURE,
+                                    absl::UnavailableError("failed"));
+  // We should see a re-resolution request.
+  ExpectReresolutionRequest();
+  // LB policy reports CONNECTNG with a queueing picker.
+  ExpectConnectingUpdate();
+  // LB policy asks the first subchannel to connect.
+  EXPECT_TRUE(subchannel->ConnectionRequested());
+  // First subchannel reports CONNECTING.
+  subchannel->SetConnectivityState(GRPC_CHANNEL_CONNECTING);
+  // Second subchannel finishes backoff while the first is still trying.
+  subchannel2->SetConnectivityState(GRPC_CHANNEL_IDLE);
+  // First subchannel fails to connect.
+  subchannel->SetConnectivityState(GRPC_CHANNEL_TRANSIENT_FAILURE,
+                                   absl::UnavailableError("failed"));
+  // LB policy asks the second subchannel to connect.
+  EXPECT_TRUE(subchannel2->ConnectionRequested());
+  // Second subchannel reports CONNECTING.
+  subchannel2->SetConnectivityState(GRPC_CHANNEL_CONNECTING);
+  // Subchannel succeeds in connecting.
+  subchannel2->SetConnectivityState(GRPC_CHANNEL_READY);
+  // LB policy reports READY.
+  picker = WaitForConnected();
+  ASSERT_NE(picker, nullptr);
+  // Picker should return the same subchannel repeatedly.
+  for (size_t i = 0; i < 3; ++i) {
+    EXPECT_EQ(ExpectPickComplete(picker.get()), kAddresses[1]);
+  }
+}
+
 TEST_F(PickFirstTest, AddressUpdateRemovedSelectedAddress) {
   // Send an update containing two addresses.
   constexpr std::array<absl::string_view, 2> kAddresses = {
@@ -1092,7 +1233,7 @@ TEST_F(PickFirstTest, AddressUpdateRemovedSelectedAddress) {
                        lb_policy());
   EXPECT_TRUE(status.ok()) << status;
   // We should see a re-resolution request.
-  ExpectReresolutionRequest();
+  if (!IsPickFirstReadyToConnectingEnabled()) ExpectReresolutionRequest();
   // LB policy reports IDLE with a queueing picker.
   ExpectStateAndQueuingPicker(GRPC_CHANNEL_IDLE);
   // By checking the picker, we told the LB policy to trigger a new