@@ -18,6 +18,7 @@ import (
@@ -81,7 +82,8 @@ func TestClientServerSpawnRace(t *testing.T) {
84− env := append(os.Environ(),
85+ env := append(
85 "CRUSH_CLIENT_SERVER=1",
87 "CRUSH_CLIENT_SERVER=1",
86 "XDG_CACHE_HOME="+cacheHome,
88 "XDG_CACHE_HOME="+cacheHome,
87 "XDG_DATA_HOME="+dataHome,
89 "XDG_DATA_HOME="+dataHome,
@@ -108,8 +110,43 @@ func TestClientServerSpawnRace(t *testing.T) {
109 results := make(chan result, numClients)
111 results := make(chan result, numClients)
113+ // Probe /v1/health concurrently while the clients are still
114+ // running. The server self-shuts-down when the last workspace is
115+ // released (internal/backend/backend.go:DeleteWorkspace), so once
116+ // all clients exit cleanly the socket may legitimately be gone —
117+ // asserting the socket post-hoc would race with that documented
118+ // self-shutdown. Instead we require that during the parallel run
119+ // at least one /v1/health probe got a 2xx, which proves the
120+ // spawn-and-readiness path actually produced a live server.
121+ var sawHealthy atomic.Bool
122+ probeDone := make(chan struct{})
123+ stopProbe := make(chan struct{})
111 var wg sync.WaitGroup
125 var wg sync.WaitGroup
112 start := make(chan struct{})
126 start := make(chan struct{})
129+ defer close(probeDone)
131+ deadline := time.Now().Add(clientTimeout)
132+ for time.Now().Before(deadline) {
138+ if err := pingHealth(socketPath); err == nil {
139+ sawHealthy.Store(true)
145+ case <-time.After(50 * time.Millisecond):
113 for i := range numClients {
150 for i := range numClients {
114 wg.Add(1)
151 wg.Add(1)
115 go func(i int) {
152 go func(i int) {
@@ -133,7 +170,8 @@ func TestClientServerSpawnRace(t *testing.T) {
133 // (e.g. waiting on event subscriptions); the context
170 // (e.g. waiting on event subscriptions); the context
134 // timeout above bounds that. We assert race outcomes
171 // timeout above bounds that. We assert race outcomes
135 // purely from output, not exit code.
172 // purely from output, not exit code.
136− c := exec.CommandContext(ctx, bin,
173+ c := exec.CommandContext(
137 "--host", host,
175 "--host", host,
138 "--cwd", cwd,
176 "--cwd", cwd,
139 "run", "hi",
177 "run", "hi",
@@ -156,6 +194,8 @@ func TestClientServerSpawnRace(t *testing.T) {
156 close(start) // release all clients as simultaneously as possible
194 close(start) // release all clients as simultaneously as possible
157 wg.Wait()
195 wg.Wait()
158 close(results)
196 close(results)
160 var raceFailures []string
200 var raceFailures []string
161 for r := range results {
201 for r := range results {
@@ -169,21 +209,23 @@ func TestClientServerSpawnRace(t *testing.T) {
171 if len(raceFailures) > 0 {
211 if len(raceFailures) > 0 {
172− t.Fatalf("client/server spawn race regressed: %d/%d clients failed\n\n%s",
213+ "client/server spawn race regressed: %d/%d clients failed\n\n%s",
173 len(raceFailures), numClients,
214 len(raceFailures), numClients,
174 strings.Join(raceFailures, "\n---\n"),
215 strings.Join(raceFailures, "\n---\n"),
178− // Positive sanity check: a single live server should be
219+ // Positive sanity check: at some point during the parallel run a
179− // reachable on the socket. If the race had fired, we'd either
220+ // /v1/health probe must have succeeded. We deliberately do *not*
180− // have no socket or a stale one with no listener.
221+ // stat the socket post-hoc: when every client returns cleanly
181− if _, err := os.Stat(socketPath); err != nil {
222+ // (e.g. exits early because no providers are configured), the
182− t.Fatalf("expected socket %s to exist after parallel clients ran, got: %v",
223+ // last DeleteWorkspace triggers the server's self-shutdown and
224+ // the socket disappears. That is correct behaviour, not a race
226+ if !sawHealthy.Load() {
227+ t.Fatalf("no /v1/health probe succeeded on %s while %d clients were running",
183− socketPath, err)
228+ socketPath, numClients)
185− if err := pingHealth(socketPath); err != nil {
186− t.Fatalf("server is not responding on %s: %v", socketPath, err)