Skip to content

Commit 8bb4ab5

Browse files
Aias00claude
andcommitted
fix(remoting): use 3-state atomic + sync.Cond for ExchangeClient.init
Address Copilot review feedback: - Replace uatomic.Bool with uatomic.Int32 for 3-state init tracking (0=uninitialized, 1=initializing, 2=initialized) to prevent callers from observing a partially-initialized state - Replace spin-wait with sync.Cond for proper coordination: waiters block until signaled instead of polling - All concurrent waiters now receive the same error result when init fails, rather than silently returning nil - Close() properly resets state and broadcasts to wake any waiters - Fix test: use buffered channel for concurrent error collection instead of calling t.Errorf from multiple goroutines Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 3dfa7fb commit 8bb4ab5

2 files changed

Lines changed: 75 additions & 20 deletions

File tree

remoting/exchange_client.go

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package remoting
1919

2020
import (
2121
"errors"
22+
"sync"
2223
"time"
2324
)
2425

@@ -61,7 +62,9 @@ type ExchangeClient struct {
6162
ConnectTimeout time.Duration // timeout for connecting server
6263
address string // server address for dialing. The format: ip:port
6364
client Client // dealing with the transport
64-
init uatomic.Bool // the tag for init, protected by atomic operations
65+
initState uatomic.Int32 // init state: 0=uninitialized, 1=initializing, 2=initialized
66+
initCond *sync.Cond // signals waiters when init completes (success or failure)
67+
initErr error // stored init error for waiters to observe (guarded by initCond.L)
6568
activeNum uatomic.Uint32 // the number of service using the exchangeClient
6669
}
6770

@@ -71,6 +74,7 @@ func NewExchangeClient(url *common.URL, client Client, connectTimeout time.Durat
7174
ConnectTimeout: connectTimeout,
7275
address: url.Location,
7376
client: client,
77+
initCond: sync.NewCond(&sync.Mutex{}),
7478
}
7579
if !lazyInit {
7680
if err := exchangeClient.doInit(url); err != nil {
@@ -82,29 +86,43 @@ func NewExchangeClient(url *common.URL, client Client, connectTimeout time.Durat
8286
}
8387

8488
func (cl *ExchangeClient) doInit(url *common.URL) error {
85-
if cl.init.Load() {
89+
// Fast path: already initialized.
90+
if cl.initState.Load() == 2 {
8691
return nil
8792
}
88-
// Use CompareAndSwap to ensure only one goroutine performs initialization.
89-
// If CAS fails, another goroutine already initialized; spin-wait until init completes.
90-
if !cl.init.CAS(false, true) {
91-
// Another goroutine is initializing; wait for it to complete.
92-
for !cl.init.Load() {
93-
time.Sleep(10 * time.Millisecond)
93+
// Try to become the initializer: transition 0 -> 1 (uninitialized -> initializing).
94+
if !cl.initState.CAS(0, 1) {
95+
// Another goroutine is initializing. Wait for it to complete.
96+
cl.initCond.L.Lock()
97+
for cl.initState.Load() == 1 {
98+
cl.initCond.Wait()
9499
}
95-
return nil
100+
err := cl.initErr
101+
cl.initCond.L.Unlock()
102+
return err
96103
}
97104
// This goroutine won the CAS — perform the actual initialization.
105+
var initErr error
98106
if cl.client.Connect(url) != nil {
99107
// retry for a while
100108
time.Sleep(100 * time.Millisecond)
101109
if cl.client.Connect(url) != nil {
102110
logger.Errorf("[Remoting] failed to connect server, url=%v", url.Location)
103-
cl.init.Store(false) // reset on failure so future calls can retry
104-
return errors.New("Failed to connect server " + url.Location)
111+
initErr = errors.New("Failed to connect server " + url.Location)
105112
}
106113
}
107-
return nil
114+
cl.initCond.L.Lock()
115+
if initErr != nil {
116+
// Reset to uninitialized so future calls can retry.
117+
cl.initState.Store(0)
118+
cl.initErr = initErr
119+
} else {
120+
cl.initState.Store(2)
121+
cl.initErr = nil
122+
}
123+
cl.initCond.L.Unlock()
124+
cl.initCond.Broadcast()
125+
return initErr
108126
}
109127

110128
// IncreaseActiveNumber increase number of service using client.
@@ -204,7 +222,11 @@ func (client *ExchangeClient) Send(invocation *base.Invocation, url *common.URL,
204222
// Close close the client.
205223
func (client *ExchangeClient) Close() {
206224
client.client.Close()
207-
client.init.Store(false)
225+
client.initCond.L.Lock()
226+
client.initState.Store(0)
227+
client.initErr = nil
228+
client.initCond.L.Unlock()
229+
client.initCond.Broadcast()
208230
}
209231

210232
// IsAvailable to check if the underlying network client is available yet.

remoting/exchange_client_test.go

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ func TestExchangeClientClose(t *testing.T) {
9292
m := &mockClient{available: true}
9393
ec := NewExchangeClient(testURL(), m, 5*time.Second, true)
9494
ec.Close()
95-
assert.False(t, ec.init.Load())
95+
assert.Equal(t, int32(0), ec.initState.Load(), "initState should be 0 (uninitialized) after Close")
9696
}
9797

9898
func TestExchangeClientIsAvailable(t *testing.T) {
@@ -112,39 +112,72 @@ func TestExchangeClientConcurrentDoInit(t *testing.T) {
112112

113113
var wg sync.WaitGroup
114114
const goroutines = 20
115+
errCh := make(chan error, goroutines)
115116
wg.Add(goroutines)
116117
for i := 0; i < goroutines; i++ {
117118
go func() {
118119
defer wg.Done()
119-
// All goroutines trigger lazy init concurrently via Request-like paths
120120
if err := ec.doInit(testURL()); err != nil {
121-
t.Errorf("doInit failed: %v", err)
121+
errCh <- err
122122
}
123123
}()
124124
}
125125
wg.Wait()
126+
close(errCh)
127+
128+
for err := range errCh {
129+
t.Errorf("doInit failed: %v", err)
130+
}
126131

127132
m.mu.Lock()
128133
count := m.connCount
129134
m.mu.Unlock()
130135
assert.Equal(t, 1, count, "Connect should be called exactly once despite concurrent doInit")
131-
assert.True(t, ec.init.Load(), "init flag should be true after doInit")
136+
assert.Equal(t, int32(2), ec.initState.Load(), "initState should be 2 (initialized) after doInit")
132137
}
133138

134139
func TestExchangeClientDoInitFailureResets(t *testing.T) {
135-
// Verify that a failed doInit resets the init flag so future calls can retry.
140+
// Verify that a failed doInit resets the init state so future calls can retry.
136141
m := &mockClient{connectErr: errors.New("fail")}
137142
ec := NewExchangeClient(testURL(), m, 5*time.Second, true)
138143

139144
err := ec.doInit(testURL())
140145
assert.Error(t, err)
141-
assert.False(t, ec.init.Load(), "init flag should be reset after failed doInit")
146+
assert.Equal(t, int32(0), ec.initState.Load(), "initState should be 0 (uninitialized) after failed doInit")
142147

143148
// Now make Connect succeed and retry
144149
m.mu.Lock()
145150
m.connectErr = nil
146151
m.mu.Unlock()
147152
err = ec.doInit(testURL())
148153
assert.NoError(t, err)
149-
assert.True(t, ec.init.Load(), "init flag should be true after successful retry")
154+
assert.Equal(t, int32(2), ec.initState.Load(), "initState should be 2 (initialized) after successful retry")
155+
}
156+
157+
func TestExchangeClientConcurrentDoInitPropagatesError(t *testing.T) {
158+
// Verify that when initialization fails, all concurrent waiters receive the same error.
159+
m := &mockClient{connectErr: errors.New("connect refused")}
160+
ec := NewExchangeClient(testURL(), m, 5*time.Second, true)
161+
162+
var wg sync.WaitGroup
163+
const goroutines = 10
164+
errCh := make(chan error, goroutines)
165+
wg.Add(goroutines)
166+
for i := 0; i < goroutines; i++ {
167+
go func() {
168+
defer wg.Done()
169+
errCh <- ec.doInit(testURL())
170+
}()
171+
}
172+
wg.Wait()
173+
close(errCh)
174+
175+
errorCount := 0
176+
for err := range errCh {
177+
if err != nil {
178+
errorCount++
179+
}
180+
}
181+
assert.Equal(t, goroutines, errorCount, "all goroutines should receive the init error")
182+
assert.Equal(t, int32(0), ec.initState.Load(), "initState should be 0 (uninitialized) after failure")
150183
}

0 commit comments

Comments
 (0)