Skip to content

Commit 674fef3

Browse files
committed
wallet: strictly enforce CAS in lifecycle state transitions
1 parent d73754a commit 674fef3

2 files changed

Lines changed: 71 additions & 13 deletions

File tree

wallet/state.go

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -136,8 +136,15 @@ func (s *walletState) toStarting() error {
136136

137137
// toStarted marks the wallet as fully started. This should be called only
138138
// after all resource initialization is complete.
139-
func (s *walletState) toStarted() {
140-
s.lifecycle.Store(uint32(lifecycleStarted))
139+
func (s *walletState) toStarted() error {
140+
if !s.lifecycle.CompareAndSwap(
141+
uint32(lifecycleStarting), uint32(lifecycleStarted)) {
142+
143+
return fmt.Errorf("%w: cannot transition to started from %v",
144+
ErrStateForbidden, lifecycle(s.lifecycle.Load()))
145+
}
146+
147+
return nil
141148
}
142149

143150
// toStopping transitions the wallet from Started to Stopping.
@@ -160,11 +167,40 @@ func (s *walletState) toStopping() error {
160167
}
161168

162169
// toStopped marks the wallet as fully stopped.
163-
func (s *walletState) toStopped() {
164-
s.lifecycle.Store(uint32(lifecycleStopped))
170+
func (s *walletState) toStopped() error {
171+
// We allow transition from Stopping (normal shutdown) or Starting
172+
// (failure during startup).
173+
//
174+
// We use a CAS loop here to handle potential races where the state
175+
// might change between Load and CompareAndSwap.
176+
//
177+
// This loop is guaranteed to terminate because:
178+
// 1. If CAS succeeds, we break.
179+
// 2. If CAS fails, it means the state changed. We reload the new state.
180+
// 3. If the new state is not Stopping or Starting (e.g. it became
181+
// Started or already Stopped), the validation check fails and we
182+
// return an error.
183+
for {
184+
current := s.lifecycle.Load()
185+
lc := lifecycle(current)
186+
187+
if lc != lifecycleStopping && lc != lifecycleStarting {
188+
return fmt.Errorf("%w: cannot transition to stopped "+
189+
"from %v", ErrStateForbidden, lc)
190+
}
191+
192+
if s.lifecycle.CompareAndSwap(
193+
current, uint32(lifecycleStopped),
194+
) {
195+
196+
break
197+
}
198+
}
165199

166200
// Force lock the wallet on shutdown for security.
167201
s.unlocked.Store(false)
202+
203+
return nil
168204
}
169205

170206
// toUnlocked marks the wallet as unlocked.

wallet/state_test.go

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ func TestStateSecureByDefault(t *testing.T) {
2525
require.NoError(t, err)
2626

2727
// Act: Transition to Started.
28-
s.toStarted()
28+
err = s.toStarted()
29+
require.NoError(t, err)
2930
require.True(t, s.isStarted())
3031
require.True(t, s.isRunning())
3132

@@ -38,7 +39,8 @@ func TestStateSecureByDefault(t *testing.T) {
3839
require.False(t, s.isRunning())
3940

4041
// Act: Transition to Stopped.
41-
s.toStopped()
42+
err = s.toStopped()
43+
require.NoError(t, err)
4244
require.False(t, s.isRunning())
4345

4446
// Assert: Invalid transition (Stop when already Stopped).
@@ -54,22 +56,26 @@ func TestStateAuthentication(t *testing.T) {
5456
s := newWalletState(syncer)
5557

5658
// Arrange: Start the wallet (must be started to be useful).
57-
s.toStarted()
59+
require.NoError(t, s.toStarting())
60+
err := s.toStarted()
61+
require.NoError(t, err)
5862

5963
// Assert: Default is Locked.
6064
require.False(t, s.isUnlocked())
6165

6266
// Act: Unlock.
6367
s.toUnlocked()
68+
require.NoError(t, err)
6469
require.True(t, s.isUnlocked())
6570

6671
// Act: Lock.
6772
s.toLocked()
73+
require.NoError(t, err)
6874
require.False(t, s.isUnlocked())
6975

7076
// Act: Verify canSign checks.
7177
// Case 1: Locked -> Error.
72-
err := s.canSign()
78+
err = s.canSign()
7379
require.ErrorIs(t, err, ErrStateForbidden)
7480
require.ErrorContains(t, err, "wallet locked")
7581

@@ -80,7 +86,9 @@ func TestStateAuthentication(t *testing.T) {
8086

8187
// Case 3: Stopped -> Error (even if unlocked, though stopped forces
8288
// lock).
83-
s.toStopped()
89+
require.NoError(t, s.toStopping())
90+
err = s.toStopped()
91+
require.NoError(t, err)
8492
// Note: toStopped forces lock, so we must check that logic too.
8593
require.False(t, s.isUnlocked())
8694

@@ -98,7 +106,8 @@ func TestStateSynchronization(t *testing.T) {
98106

99107
syncer := &mockChainSyncer{}
100108
s := newWalletState(syncer)
101-
s.toStarted()
109+
require.NoError(t, s.toStarting())
110+
require.NoError(t, s.toStarted())
102111

103112
// Arrange: Mock syncer to return Synced.
104113
syncer.On("syncState").Return(syncStateSynced)
@@ -150,8 +159,18 @@ func TestStateThreadSafety(t *testing.T) {
150159
defer wg.Done()
151160

152161
<-start
162+
163+
// NOTE: We ignore errors here because we are
164+
// purposefully hammering the state machine from
165+
// multiple goroutines. Many of these transitions will
166+
// fail (e.g., trying to start an already starting
167+
// wallet), which is expected behavior. We are
168+
// primarily verifying that no data races or panics
169+
// occur.
170+
//
153171
// Try to start.
154172
_ = s.toStarting()
173+
155174
// Try to stop.
156175
_ = s.toStopping()
157176
}()
@@ -178,7 +197,8 @@ func TestValidateSynced(t *testing.T) {
178197
require.ErrorIs(t, err, ErrStateForbidden)
179198

180199
// Case 2: Started but not synced.
181-
s.toStarted()
200+
require.NoError(t, s.toStarting())
201+
require.NoError(t, s.toStarted())
182202
syncer.On("syncState").Return(syncStateSyncing)
183203

184204
err = s.validateSynced()
@@ -273,7 +293,8 @@ func TestStateStartStop(t *testing.T) {
273293
require.False(t, state.unlocked.Load())
274294

275295
// Now mark as started.
276-
state.toStarted()
296+
err = state.toStarted()
297+
require.NoError(t, err)
277298
require.Equal(t, uint32(lifecycleStarted),
278299
state.lifecycle.Load())
279300
})
@@ -410,7 +431,8 @@ func TestStateAuxiliaryMethods(t *testing.T) {
410431
require.ErrorIs(t, s.canChangePassphrase(), ErrStateForbidden)
411432

412433
// Case 2: Started -> All allowed.
413-
s.toStarted()
434+
require.NoError(t, s.toStarting())
435+
require.NoError(t, s.toStarted())
414436
require.NoError(t, s.canUnlock())
415437
require.NoError(t, s.canLock())
416438
require.NoError(t, s.canChangePassphrase())

0 commit comments

Comments
 (0)