fix(client): keep the run's exit signal readable after supervisor Stop

Stop cleared the done channel, so a waiter that raced a teardown got nil
from Done() and selected on a nil channel: an Up racing a Down hung in
waitForUp for its full 50s timeout where it previously failed fast with
"client gave up to connect". The same clearing made Alive() report a
timed-out run dead while its goroutine was still tearing down, letting
Up start an overlapping second run.

Keep the channel in place instead: the exiting run closes it, so late
waiters observe the real exit and Alive() stays true until the run is
actually gone — the lifecycle clientGiveUpChan used to have. Stop still
drops the current client and invalidates the generation.

The stale-running-chan test begins a fresh run before waiting, as Up
does, so the kept exit signal does not race the stale clientRunningChan.
This commit is contained in:
Zoltán Papp
2026-08-27 11:27:25 +02:00
parent 5df35e3e27
commit 3615c01fe3
3 changed files with 26 additions and 3 deletions
+4 -3
View File
@@ -72,8 +72,10 @@ func (s *RunSupervisor) Current() *ConnectClient {
return s.current
}
// Done returns the channel the current run closes when it exits, or nil when no
// run has been started. Callers that only need a yes/no answer should use Alive.
// Done returns the channel the most recent run closes when it exits, or nil
// when no run has been started. It stays readable after Stop so a waiter that
// raced a teardown still observes the run's exit instead of a nil channel.
// Callers that only need a yes/no answer should use Alive.
func (s *RunSupervisor) Done() <-chan struct{} {
s.mu.Lock()
defer s.mu.Unlock()
@@ -110,7 +112,6 @@ func (s *RunSupervisor) Stop(ctx context.Context) error {
cc := s.current
done := s.done
s.current = nil
s.done = nil
s.mu.Unlock()
if cc != nil {
+17
View File
@@ -96,6 +96,23 @@ func TestRunSupervisorStopGivesUpWaitOnContext(t *testing.T) {
assert.ErrorIs(t, s.Stop(ctx), context.DeadlineExceeded)
}
func TestRunSupervisorDoneSurvivesStop(t *testing.T) {
var s RunSupervisor
_, done := s.Begin()
close(done)
require.NoError(t, s.Stop(context.Background()))
runDone := s.Done()
require.NotNil(t, runDone, "a waiter that raced Stop must still observe the run's exit")
select {
case <-runDone:
default:
t.Fatal("the last run's exit signal should remain readable after Stop")
}
}
func TestRunSupervisorAliveTracksRun(t *testing.T) {
var s RunSupervisor
+5
View File
@@ -190,6 +190,11 @@ func TestDownThenUp_StaleRunningChan(t *testing.T) {
assert.False(t, s.clientRunning, "clientRunning should be cleared by cleanupConnection (intent = down)")
s.mutex.Unlock()
// A fresh Up begins a new run before waiting; without it the previous
// run's kept exit signal would race the stale clientRunningChan below.
_, nextDone := s.runs.Begin()
defer close(nextDone)
// waitForUp() returns immediately due to stale closed clientRunningChan
waitCtx, ctxCancel := context.WithTimeout(context.Background(), 2*time.Second)
defer ctxCancel()