Version Information
ev-node: main at 5c88745ce84300cf67dcc7185bbf7c54822e3233
System Information
This is a platform-independent concurrency issue.
Reproduced on:
Network Configuration
No response
Development Environment
No response
Configuration Details
No response
Steps to Reproduce
Subscriber.Start currently publishes the new cancel function before
registering its background tasks with the WaitGroup:
- Lock
lifecycleMu - Store
s.cancel - Unlock
lifecycleMu - Call
s.wg.Add(2) - Start the worker goroutines
Subscriber.Stop performs the following:
- Lock
lifecycleMu - Read
s.cancel - Set
s.cancel = nil - Unlock
lifecycleMu - Cancel the context
- Call
s.wg.Wait()
This allows two problematic interleavings.
Interleaving 1: Stop waits before Start registers its tasks
Start stores s.cancel and unlocks.Stop acquires the lock and clears s.cancel.Stop cancels the context and calls wg.Wait().- The WaitGroup counter is still zero, so
Wait() returns. Start subsequently calls wg.Add(2) and launches the workers.
The workers receive an already-canceled context and will normally exit quickly, but they may start after Stop has returned and are not joined by that Stop call.
Interleaving 2: A new generation starts while the old generation is stopping
- Generation 1 is running.
Stop clears s.cancel, cancels generation 1, and waits for it to exit.- Before generation 1 has exited, another
Start observes s.cancel == nil. Start registers and launches generation 2 using the same WaitGroup.
This allows both generations to overlap. The first Stop may then wait for generation 2, even though it did not cancel generation 2. Reusing the WaitGroup around the previous generation's zero transition may also result in:
sync: WaitGroup misuse: Add called concurrently with Waitsync: WaitGroup is reused before previous Wait has returned
A deterministic reproduction can use a fake DA client whose first Subscribe call remains blocked after its context is canceled. Calling Start again while Stop is waiting causes a second Subscribe call to begin before the first one has exited.
Expected Result
- WaitGroup tasks are registered before the running state becomes observable.
Stop does not return until all workers from the stopped generation exit.- A new generation cannot start while the previous generation is stopping.
- Repeated or concurrent
Start and Stop calls are safe and idempotent. - A subscriber can be started again after a completed stop.
Actual Result
Stop may return before the corresponding workers have started and exited.- A second generation may start while the first generation is stopping.
- A
Stop call may wait for a newer, uncanceled generation. - WaitGroup reuse can violate the documented
Add/Wait ordering rules.
Relevant Logs
Additional Information
Proposed Fix:
Serialize lifecycle transitions explicitly, for example with:
Required invariants:
- Register the generation's WaitGroup tasks before publishing
running. - Keep the subscriber in
stopping until all old workers have exited. - Do not start a new generation while the state is
stopping. - Transition back to
stopped only after wg.Wait() completes. - Make concurrent and repeated
Stop calls wait for the same stop completion.
An equivalent implementation that preserves these invariants would also be
acceptable.
Version Information
ev-node:
mainat5c88745ce84300cf67dcc7185bbf7c54822e3233System Information
This is a platform-independent concurrency issue.
Reproduced on:
Network Configuration
No response
Development Environment
No response
Configuration Details
No response
Steps to Reproduce
Subscriber.Startcurrently publishes the new cancel function beforeregistering its background tasks with the WaitGroup:
lifecycleMus.cancellifecycleMus.wg.Add(2)Subscriber.Stopperforms the following:lifecycleMus.cancels.cancel = nillifecycleMus.wg.Wait()This allows two problematic interleavings.
Interleaving 1: Stop waits before Start registers its tasks
Startstoress.canceland unlocks.Stopacquires the lock and clearss.cancel.Stopcancels the context and callswg.Wait().Wait()returns.Startsubsequently callswg.Add(2)and launches the workers.The workers receive an already-canceled context and will normally exit quickly, but they may start after
Stophas returned and are not joined by thatStopcall.Interleaving 2: A new generation starts while the old generation is stopping
Stopclearss.cancel, cancels generation 1, and waits for it to exit.Startobservess.cancel == nil.Startregisters and launches generation 2 using the same WaitGroup.This allows both generations to overlap. The first
Stopmay then wait for generation 2, even though it did not cancel generation 2. Reusing the WaitGroup around the previous generation's zero transition may also result in:sync: WaitGroup misuse: Add called concurrently with Waitsync: WaitGroup is reused before previous Wait has returnedA deterministic reproduction can use a fake DA client whose first
Subscribecall remains blocked after its context is canceled. CallingStartagain whileStopis waiting causes a secondSubscribecall to begin before the first one has exited.Expected Result
Stopdoes not return until all workers from the stopped generation exit.StartandStopcalls are safe and idempotent.Actual Result
Stopmay return before the corresponding workers have started and exited.Stopcall may wait for a newer, uncanceled generation.Add/Waitordering rules.Relevant Logs
Additional Information
Proposed Fix:
Serialize lifecycle transitions explicitly, for example with:
stoppedrunningstoppingRequired invariants:
running.stoppinguntil all old workers have exited.stopping.stoppedonly afterwg.Wait()completes.Stopcalls wait for the same stop completion.An equivalent implementation that preserves these invariants would also be
acceptable.