Skip to content

Commit fba7b07

Browse files
committed
a89 wrr changes adding backend service to per call metrics
a94 subchannel metrics with labels
1 parent e816736 commit fba7b07

File tree

13 files changed

+460
-31
lines changed

13 files changed

+460
-31
lines changed

balancer/pickfirst/metrics_test.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,11 +102,31 @@ func (s) TestPickFirstMetrics(t *testing.T) {
102102
t.Errorf("Unexpected data for metric %v, got: %v, want: %v", "grpc.lb.pick_first.disconnections", got, 0)
103103
}
104104

105+
//Checking for subchannel metrics as well
106+
if got, _ := tmr.Metric("grpc.subchannel.connection_attempts_succeeded"); got != 1 {
107+
t.Errorf("Unexpected data for metric %v, got: %v, want: %v", "grpc.subchannel.connection_attempts_succeeded", got, 1)
108+
}
109+
if got, _ := tmr.Metric("grpc.subchannel.connection_attempts_failed"); got != 0 {
110+
t.Errorf("Unexpected data for metric %v, got: %v, want: %v", "grpc.subchannel.connection_attempts_failed", got, 0)
111+
}
112+
if got, _ := tmr.Metric("grpc.subchannel.disconnections"); got != 0 {
113+
t.Errorf("Unexpected data for metric %v, got: %v, want: %v", "grpc.subchannel.disconnections", got, 0)
114+
}
115+
if got, _ := tmr.Metric("grpc.subchannel.open_connections"); got != 1 {
116+
t.Errorf("Unexpected data for metric %v, got: %v, want: %v", "grpc.subchannel.open_connections", got, 1)
117+
}
118+
105119
ss.Stop()
106120
testutils.AwaitState(ctx, t, cc, connectivity.Idle)
107121
if got, _ := tmr.Metric("grpc.lb.pick_first.disconnections"); got != 1 {
108122
t.Errorf("Unexpected data for metric %v, got: %v, want: %v", "grpc.lb.pick_first.disconnections", got, 1)
109123
}
124+
if got, _ := tmr.Metric("grpc.subchannel.disconnections"); got != 1 {
125+
t.Errorf("Unexpected data for metric %v, got: %v, want: %v", "grpc.subchannel.disconnections", got, 1)
126+
}
127+
if got, _ := tmr.Metric("grpc.subchannel.open_connections"); got != -1 {
128+
t.Errorf("Unexpected data for metric %v, got: %v, want: %v", "grpc.subchannel.open_connections", got, -1)
129+
}
110130
}
111131

112132
// TestPickFirstMetricsFailure tests the connection attempts failed metric. It

balancer/pickfirst/pickfirst_ext_test.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"encoding/json"
2424
"errors"
2525
"fmt"
26+
"slices"
2627
"strings"
2728
"sync"
2829
"testing"
@@ -1946,6 +1947,20 @@ func (s) TestPickFirstLeaf_HappyEyeballs_TF_AfterEndOfList(t *testing.T) {
19461947
if got, _ := tmr.Metric("grpc.lb.pick_first.disconnections"); got != 0 {
19471948
t.Errorf("Unexpected data for metric %v, got: %v, want: %v", "grpc.lb.pick_first.disconnections", got, 0)
19481949
}
1950+
1951+
if got, _ := tmr.Metric("grpc.subchannel.connection_attempts_succeeded"); got != 0 {
1952+
t.Errorf("Unexpected data for metric %v, got: %v, want: %v", "grpc.subchannel.connection_attempts_succeeded", got, 0)
1953+
}
1954+
if got, _ := tmr.Metric("grpc.subchannel.connection_attempts_failed"); got != 1 {
1955+
t.Errorf("Unexpected data for metric %v, got: %v, want: %v", "grpc.subchannel.connection_attempts_failed", got, 1)
1956+
}
1957+
expectedLabels := []string{"whatever:///test.server", "", "{region=\"\", zone=\"\", sub_zone=\"\"}"}
1958+
if detail, _ := tmr.MetricDetail("grpc.subchannel.connection_attempts_failed"); !slices.Equal(detail.LabelVals, expectedLabels) {
1959+
t.Errorf("Unexpected label values for metric %v, got: %v, want %v", "grpc.subchannel.connection_attempts_failed", detail.LabelVals, expectedLabels)
1960+
}
1961+
if got, _ := tmr.Metric("grpc.subchannel.disconnections"); got != 0 {
1962+
t.Errorf("Unexpected data for metric %v, got: %v, want: %v", "grpc.subchannel.disconnections", got, 0)
1963+
}
19491964
}
19501965

19511966
// Test verifies that pickfirst attempts to connect to the second backend once
@@ -2006,6 +2021,27 @@ func (s) TestPickFirstLeaf_HappyEyeballs_TriggerConnectionDelay(t *testing.T) {
20062021
if got, _ := tmr.Metric("grpc.lb.pick_first.disconnections"); got != 0 {
20072022
t.Errorf("Unexpected data for metric %v, got: %v, want: %v", "grpc.lb.pick_first.disconnections", got, 0)
20082023
}
2024+
2025+
if got, _ := tmr.Metric("grpc.subchannel.connection_attempts_succeeded"); got != 1 {
2026+
t.Errorf("Unexpected data for metric %v, got: %v, want: %v", "grpc.subchannel.connection_attempts_succeeded", got, 1)
2027+
}
2028+
expectedLabels := []string{"whatever:///test.server", "", "{region=\"\", zone=\"\", sub_zone=\"\"}"}
2029+
if got, _ := tmr.MetricDetail("grpc.subchannel.connection_attempts_succeeded"); !slices.Equal(got.LabelVals, expectedLabels) {
2030+
t.Errorf("Unexpected data for metric %v, got: %s, want: %s", "grpc.subchannel.connection_attempts_succeeded", got.LabelVals, expectedLabels)
2031+
}
2032+
if got, _ := tmr.Metric("grpc.subchannel.connection_attempts_failed"); got != 0 {
2033+
t.Errorf("Unexpected data for metric %v, got: %v, want: %v", "grpc.subchannel.connection_attempts_failed", got, 0)
2034+
}
2035+
if got, _ := tmr.Metric("grpc.subchannel.disconnections"); got != 0 {
2036+
t.Errorf("Unexpected data for metric %v, got: %v, want: %v", "grpc.subchannel.disconnections", got, 0)
2037+
}
2038+
if got, _ := tmr.Metric("grpc.subchannel.open_connections"); got != 1 {
2039+
t.Errorf("Unexpected data for metric %v, got: %v, want: %v", "grpc.subchannel.open_connections", got, 1)
2040+
}
2041+
expectedLabels = []string{"whatever:///test.server", "", "NoSecurity", "{region=\"\", zone=\"\", sub_zone=\"\"}"}
2042+
if got, _ := tmr.MetricDetail("grpc.subchannel.open_connections"); !slices.Equal(got.LabelVals, expectedLabels) {
2043+
t.Errorf("Unexpected data for metric %v, got: %s, want: %s", "grpc.subchannel.open_connections", got.LabelVals, expectedLabels)
2044+
}
20092045
}
20102046

20112047
// Test tests the pickfirst balancer by causing a SubConn to fail and then
@@ -2057,6 +2093,13 @@ func (s) TestPickFirstLeaf_HappyEyeballs_TF_ThenTimerFires(t *testing.T) {
20572093
if got, _ := tmr.Metric("grpc.lb.pick_first.connection_attempts_failed"); got != 1 {
20582094
t.Errorf("Unexpected data for metric %v, got: %v, want: %v", "grpc.lb.pick_first.connection_attempts_failed", got, 1)
20592095
}
2096+
if got, _ := tmr.Metric("grpc.subchannel.connection_attempts_failed"); got != 1 {
2097+
t.Errorf("Unexpected data for metric %v, got: %v, want: %v", "grpc.subchannel.connection_attempts_failed", got, 1)
2098+
}
2099+
expectedLabels := []string{"whatever:///test.server", "", "{region=\"\", zone=\"\", sub_zone=\"\"}"}
2100+
if got, _ := tmr.MetricDetail("grpc.subchannel.connection_attempts_failed"); !slices.Equal(got.LabelVals, expectedLabels) {
2101+
t.Errorf("Unexpected data for metric %v, got: %s, want: %s", "grpc.subchannel.connection_attempts_failed", got.LabelVals, expectedLabels)
2102+
}
20602103
if holds[2].IsStarted() != false {
20612104
t.Fatalf("Server %d with address %q contacted unexpectedly", 2, addrs[2])
20622105
}
@@ -2080,6 +2123,17 @@ func (s) TestPickFirstLeaf_HappyEyeballs_TF_ThenTimerFires(t *testing.T) {
20802123
if got, _ := tmr.Metric("grpc.lb.pick_first.disconnections"); got != 0 {
20812124
t.Errorf("Unexpected data for metric %v, got: %v, want: %v", "grpc.lb.pick_first.disconnections", got, 0)
20822125
}
2126+
2127+
if got, _ := tmr.Metric("grpc.subchannel.connection_attempts_succeeded"); got != 1 {
2128+
t.Errorf("Unexpected data for metric %v, got: %v, want: %v", "grpc.subchannel.connection_attempts_succeeded", got, 1)
2129+
}
2130+
expectedLabels = []string{"whatever:///test.server", "", "{region=\"\", zone=\"\", sub_zone=\"\"}"}
2131+
if got, _ := tmr.MetricDetail("grpc.subchannel.connection_attempts_succeeded"); !slices.Equal(got.LabelVals, expectedLabels) {
2132+
t.Errorf("Unexpected data for metric %v, got: %s, want: %s", "grpc.subchannel.connection_attempts_succeeded", got.LabelVals, expectedLabels)
2133+
}
2134+
if got, _ := tmr.Metric("grpc.subchannel.disconnections"); got != 0 {
2135+
t.Errorf("Unexpected data for metric %v, got: %v, want: %v", "grpc.subchannel.disconnections", got, 0)
2136+
}
20832137
}
20842138

20852139
func (s) TestPickFirstLeaf_InterleavingIPV4Preferred(t *testing.T) {

balancer/weightedroundrobin/balancer.go

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ var (
6262
Description: "EXPERIMENTAL. Number of scheduler updates in which there were not enough endpoints with valid weight, which caused the WRR policy to fall back to RR behavior.",
6363
Unit: "{update}",
6464
Labels: []string{"grpc.target"},
65-
OptionalLabels: []string{"grpc.lb.locality"},
65+
OptionalLabels: []string{"grpc.lb.locality", "grpc.lb.backend_service"},
6666
Default: false,
6767
})
6868

@@ -71,7 +71,7 @@ var (
7171
Description: "EXPERIMENTAL. Number of endpoints from each scheduler update that don't yet have usable weight information (i.e., either the load report has not yet been received, or it is within the blackout period).",
7272
Unit: "{endpoint}",
7373
Labels: []string{"grpc.target"},
74-
OptionalLabels: []string{"grpc.lb.locality"},
74+
OptionalLabels: []string{"grpc.lb.locality", "grpc.lb.backend_service"},
7575
Default: false,
7676
})
7777

@@ -80,15 +80,15 @@ var (
8080
Description: "EXPERIMENTAL. Number of endpoints from each scheduler update whose latest weight is older than the expiration period.",
8181
Unit: "{endpoint}",
8282
Labels: []string{"grpc.target"},
83-
OptionalLabels: []string{"grpc.lb.locality"},
83+
OptionalLabels: []string{"grpc.lb.locality", "grpc.lb.backend_service"},
8484
Default: false,
8585
})
8686
endpointWeightsMetric = estats.RegisterFloat64Histo(estats.MetricDescriptor{
8787
Name: "grpc.lb.wrr.endpoint_weights",
8888
Description: "EXPERIMENTAL. Weight of each endpoint, recorded on every scheduler update. Endpoints without usable weights will be recorded as weight 0.",
8989
Unit: "{endpoint}",
9090
Labels: []string{"grpc.target"},
91-
OptionalLabels: []string{"grpc.lb.locality"},
91+
OptionalLabels: []string{"grpc.lb.locality", "grpc.lb.backend_service"},
9292
Default: false,
9393
})
9494
)
@@ -173,6 +173,7 @@ func (b *wrrBalancer) updateEndpointsLocked(endpoints []resolver.Endpoint) {
173173
metricsRecorder: b.metricsRecorder,
174174
target: b.target,
175175
locality: b.locality,
176+
cluster: b.clusterName,
176177
}
177178
for _, addr := range endpoint.Addresses {
178179
b.addressWeights.Set(addr, ew)
@@ -211,6 +212,7 @@ type wrrBalancer struct {
211212
mu sync.Mutex
212213
cfg *lbConfig // active config
213214
locality string
215+
clusterName string
214216
stopPicker *grpcsync.Event
215217
addressWeights *resolver.AddressMapV2[*endpointWeight]
216218
endpointToWeight *resolver.EndpointMap[*endpointWeight]
@@ -231,6 +233,11 @@ func (b *wrrBalancer) UpdateClientConnState(ccs balancer.ClientConnState) error
231233
b.mu.Lock()
232234
b.cfg = cfg
233235
b.locality = weightedtarget.LocalityFromResolverState(ccs.ResolverState)
236+
if cluster, ok := resolver.GetBackendServiceFromState(ccs.ResolverState); !ok {
237+
b.logger.Infof("Backend service name not found in resolver state attributes.")
238+
} else {
239+
b.clusterName = cluster
240+
}
234241
b.updateEndpointsLocked(ccs.ResolverState.Endpoints)
235242
b.mu.Unlock()
236243

@@ -288,6 +295,7 @@ func (b *wrrBalancer) UpdateState(state balancer.State) {
288295
metricsRecorder: b.metricsRecorder,
289296
locality: b.locality,
290297
target: b.target,
298+
clusterName: b.clusterName,
291299
}
292300

293301
b.stopPicker = grpcsync.NewEvent()
@@ -420,6 +428,7 @@ type picker struct {
420428
// The following fields are immutable.
421429
target string
422430
locality string
431+
clusterName string
423432
metricsRecorder estats.MetricsRecorder
424433
}
425434

@@ -499,6 +508,7 @@ type endpointWeight struct {
499508
target string
500509
metricsRecorder estats.MetricsRecorder
501510
locality string
511+
cluster string
502512

503513
// The following fields are only accessed on calls into the LB policy, and
504514
// do not need a mutex.
@@ -602,14 +612,14 @@ func (w *endpointWeight) weight(now time.Time, weightExpirationPeriod, blackoutP
602612

603613
if recordMetrics {
604614
defer func() {
605-
endpointWeightsMetric.Record(w.metricsRecorder, weight, w.target, w.locality)
615+
endpointWeightsMetric.Record(w.metricsRecorder, weight, w.target, w.locality, w.cluster)
606616
}()
607617
}
608618

609619
// The endpoint has not received a load report (i.e. just turned READY with
610620
// no load report).
611621
if w.lastUpdated.Equal(time.Time{}) {
612-
endpointWeightNotYetUsableMetric.Record(w.metricsRecorder, 1, w.target, w.locality)
622+
endpointWeightNotYetUsableMetric.Record(w.metricsRecorder, 1, w.target, w.locality, w.cluster)
613623
return 0
614624
}
615625

@@ -618,7 +628,7 @@ func (w *endpointWeight) weight(now time.Time, weightExpirationPeriod, blackoutP
618628
// start getting data again in the future, and return 0.
619629
if now.Sub(w.lastUpdated) >= weightExpirationPeriod {
620630
if recordMetrics {
621-
endpointWeightStaleMetric.Record(w.metricsRecorder, 1, w.target, w.locality)
631+
endpointWeightStaleMetric.Record(w.metricsRecorder, 1, w.target, w.locality, w.cluster)
622632
}
623633
w.nonEmptySince = time.Time{}
624634
return 0
@@ -627,7 +637,7 @@ func (w *endpointWeight) weight(now time.Time, weightExpirationPeriod, blackoutP
627637
// If we don't have at least blackoutPeriod worth of data, return 0.
628638
if blackoutPeriod != 0 && (w.nonEmptySince.Equal(time.Time{}) || now.Sub(w.nonEmptySince) < blackoutPeriod) {
629639
if recordMetrics {
630-
endpointWeightNotYetUsableMetric.Record(w.metricsRecorder, 1, w.target, w.locality)
640+
endpointWeightNotYetUsableMetric.Record(w.metricsRecorder, 1, w.target, w.locality, w.cluster)
631641
}
632642
return 0
633643
}

balancer/weightedroundrobin/scheduler.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ func (p *picker) newScheduler(recordMetrics bool) scheduler {
3939
}
4040
if n == 1 {
4141
if recordMetrics {
42-
rrFallbackMetric.Record(p.metricsRecorder, 1, p.target, p.locality)
42+
rrFallbackMetric.Record(p.metricsRecorder, 1, p.target, p.locality, p.clusterName)
4343
}
4444
return &rrScheduler{numSCs: 1, inc: p.inc}
4545
}
@@ -58,7 +58,7 @@ func (p *picker) newScheduler(recordMetrics bool) scheduler {
5858

5959
if numZero >= n-1 {
6060
if recordMetrics {
61-
rrFallbackMetric.Record(p.metricsRecorder, 1, p.target, p.locality)
61+
rrFallbackMetric.Record(p.metricsRecorder, 1, p.target, p.locality, p.clusterName)
6262
}
6363
return &rrScheduler{numSCs: uint32(n), inc: p.inc}
6464
}

clientconn.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,16 @@ import (
3535
"google.golang.org/grpc/balancer/pickfirst"
3636
"google.golang.org/grpc/codes"
3737
"google.golang.org/grpc/connectivity"
38+
"google.golang.org/grpc/credentials"
39+
expstats "google.golang.org/grpc/experimental/stats"
3840
"google.golang.org/grpc/internal"
3941
"google.golang.org/grpc/internal/channelz"
4042
"google.golang.org/grpc/internal/grpcsync"
4143
"google.golang.org/grpc/internal/idle"
4244
iresolver "google.golang.org/grpc/internal/resolver"
4345
istats "google.golang.org/grpc/internal/stats"
4446
"google.golang.org/grpc/internal/transport"
47+
xdsinternal "google.golang.org/grpc/internal/xds"
4548
"google.golang.org/grpc/keepalive"
4649
"google.golang.org/grpc/resolver"
4750
"google.golang.org/grpc/serviceconfig"
@@ -98,6 +101,41 @@ var (
98101
errTransportCredentialsMissing = errors.New("grpc: the credentials require transport level security (use grpc.WithTransportCredentials() to set)")
99102
)
100103

104+
var (
105+
disconnectionsMetric = expstats.RegisterInt64Count(expstats.MetricDescriptor{
106+
Name: "grpc.subchannel.disconnections",
107+
Description: "EXPERIMENTAL. Number of times the selected subchannel becomes disconnected.",
108+
Unit: "{disconnection}",
109+
Labels: []string{"grpc.target"},
110+
OptionalLabels: []string{"grpc.lb.backend_service", "grpc.lb.locality", "grpc.disconnect_error"},
111+
Default: false,
112+
})
113+
connectionAttemptsSucceededMetric = expstats.RegisterInt64Count(expstats.MetricDescriptor{
114+
Name: "grpc.subchannel.connection_attempts_succeeded",
115+
Description: "EXPERIMENTAL. Number of successful connection attempts.",
116+
Unit: "{attempt}",
117+
Labels: []string{"grpc.target"},
118+
OptionalLabels: []string{"grpc.lb.backend_service", "grpc.lb.locality"},
119+
Default: false,
120+
})
121+
connectionAttemptsFailedMetric = expstats.RegisterInt64Count(expstats.MetricDescriptor{
122+
Name: "grpc.subchannel.connection_attempts_failed",
123+
Description: "EXPERIMENTAL. Number of failed connection attempts.",
124+
Unit: "{attempt}",
125+
Labels: []string{"grpc.target"},
126+
OptionalLabels: []string{"grpc.lb.backend_service", "grpc.lb.locality"},
127+
Default: false,
128+
})
129+
openConnectionsMetric = expstats.RegisterInt64UpDownCount(expstats.MetricDescriptor{
130+
Name: "grpc.subchannel.open_connections",
131+
Description: "EXPERIMENTAL. Number of open connections.",
132+
Unit: "{attempt}",
133+
Labels: []string{"grpc.target"},
134+
OptionalLabels: []string{"grpc.lb.backend_service", "grpc.security_level", "grpc.lb.locality"},
135+
Default: false,
136+
})
137+
)
138+
101139
const (
102140
defaultClientMaxReceiveMessageSize = 1024 * 1024 * 4
103141
defaultClientMaxSendMessageSize = math.MaxInt32
@@ -1223,6 +1261,18 @@ func (ac *addrConn) updateConnectivityState(s connectivity.State, lastErr error)
12231261
if ac.state == s {
12241262
return
12251263
}
1264+
1265+
var locality, backendService string
1266+
if len(ac.addrs) > 0 {
1267+
labels := xdsinternal.AddressToTelemetryLabels(ac.addrs[0])
1268+
locality = labels["grpc.lb.locality"]
1269+
backendService = labels["grpc.lb.backend_service"]
1270+
}
1271+
1272+
if ac.state == connectivity.Ready || (ac.state == connectivity.Connecting && s == connectivity.Idle) {
1273+
disconnectionsMetric.Record(ac.cc.metricsRecorderList, 1, ac.cc.target, backendService, locality, "unknown")
1274+
openConnectionsMetric.Record(ac.cc.metricsRecorderList, -1, ac.cc.target, backendService, ac.securityLevel(), locality)
1275+
}
12261276
ac.state = s
12271277
ac.channelz.ChannelMetrics.State.Store(&s)
12281278
if lastErr == nil {
@@ -1276,10 +1326,18 @@ func (ac *addrConn) resetTransportAndUnlock() {
12761326
// https://github.com/grpc/grpc/blob/master/doc/connection-backoff.md#proposed-backoff-algorithm
12771327
connectDeadline := time.Now().Add(dialDuration)
12781328

1329+
var locality, backendService string
1330+
if len(ac.addrs) > 0 {
1331+
labels := xdsinternal.AddressToTelemetryLabels(ac.addrs[0])
1332+
locality = labels["grpc.lb.locality"]
1333+
backendService = labels["grpc.lb.backend_service"]
1334+
}
1335+
12791336
ac.updateConnectivityState(connectivity.Connecting, nil)
12801337
ac.mu.Unlock()
12811338

12821339
if err := ac.tryAllAddrs(acCtx, addrs, connectDeadline); err != nil {
1340+
connectionAttemptsFailedMetric.Record(ac.cc.metricsRecorderList, 1, ac.cc.target, backendService, locality)
12831341
// TODO: #7534 - Move re-resolution requests into the pick_first LB policy
12841342
// to ensure one resolution request per pass instead of per subconn failure.
12851343
ac.cc.resolveNow(resolver.ResolveNowOptions{})
@@ -1319,10 +1377,30 @@ func (ac *addrConn) resetTransportAndUnlock() {
13191377
}
13201378
// Success; reset backoff.
13211379
ac.mu.Lock()
1380+
connectionAttemptsSucceededMetric.Record(ac.cc.metricsRecorderList, 1, ac.cc.target, backendService, locality)
1381+
openConnectionsMetric.Record(ac.cc.metricsRecorderList, 1, ac.cc.target, backendService, ac.securityLevel(), locality)
13221382
ac.backoffIdx = 0
13231383
ac.mu.Unlock()
13241384
}
13251385

1386+
type securityLevelKey struct{}
1387+
1388+
func (ac *addrConn) securityLevel() string {
1389+
var secLevel string
1390+
if ac.transport == nil {
1391+
secLevel, _ = ac.curAddr.Attributes.Value(securityLevelKey{}).(string)
1392+
return secLevel
1393+
}
1394+
authInfo := ac.transport.AuthInfo()
1395+
if ci, ok := authInfo.(interface {
1396+
GetCommonAuthInfo() credentials.CommonAuthInfo
1397+
}); ok {
1398+
secLevel = ci.GetCommonAuthInfo().SecurityLevel.String()
1399+
ac.curAddr.Attributes = ac.curAddr.Attributes.WithValue(securityLevelKey{}, secLevel)
1400+
}
1401+
return secLevel
1402+
}
1403+
13261404
// tryAllAddrs tries to create a connection to the addresses, and stop when at
13271405
// the first successful one. It returns an error if no address was successfully
13281406
// connected, or updates ac appropriately with the new transport.

0 commit comments

Comments
 (0)