@@ -402,9 +402,10 @@ func ensureServer(cmd *cobra.Command, hostURL *url.URL) error {
402402 }
403403
404404 if needsStart {
405− if err := startDetachedServer(cmd); err != nil {
406− return err
405+ if err := spawnAndWaitReady(cmd, hostURL); err != nil {
406+ return fmt.Errorf("failed to initialize crush server: %v", err)
407407 }
408+ return nil
408409 }
409410
410411 if err := waitForServerReady(cmd.Context(), hostURL); err != nil {
@@ -415,6 +416,70 @@ func ensureServer(cmd *cobra.Command, hostURL *url.URL) error {
415416 return nil
416417}
417418
419+// spawnAndWaitReady serializes the spawn-and-wait-for-readiness sequence
420+// across concurrent clients via an exclusive flock on
421+// $XDG_CACHE_HOME/crush/server-<safeHost>/start.lock.
422+//
423+// After acquiring the lock it re-probes readiness so that a client that
424+// blocked while another client was spawning can skip its own spawn and
425+// just use the now-running server. The lock is held only for the
426+// duration of "spawn + readiness probe" and released before the caller
427+// resumes its normal lifetime.
428+func spawnAndWaitReady(cmd *cobra.Command, hostURL *url.URL) error {
429+ chDir, err := perHostServerDir()
430+ if err != nil {
431+ return err
432+ }
433+ release, err := acquireSpawnLock(filepath.Join(chDir, "start.lock"))
434+ if err != nil {
435+ // If the lock itself is unavailable, fall back to the
436+ // unsynchronized path rather than blocking the user.
437+ slog.Warn("Failed to acquire spawn lock, proceeding without single-flight", "error", err)
438+ if err := startDetachedServer(cmd); err != nil {
439+ return err
440+ }
441+ return waitForServerReady(cmd.Context(), hostURL)
442+ }
443+ defer release()
444+
445+ // Another client may have just finished spawning while we were
446+ // waiting on the lock; if the server is already responsive, skip
447+ // the spawn entirely.
448+ probeCtx, cancel := context.WithTimeout(cmd.Context(), 200*time.Millisecond)
449+ probeErr := quickHealthProbe(probeCtx, hostURL)
450+ cancel()
451+ if probeErr == nil {
452+ return nil
453+ }
454+
455+ if err := startDetachedServer(cmd); err != nil {
456+ return err
457+ }
458+ return waitForServerReady(cmd.Context(), hostURL)
459+}
460+
461+// quickHealthProbe issues a single readiness request with the caller's
462+// context and returns nil iff the server is responsive right now.
463+func quickHealthProbe(ctx context.Context, hostURL *url.URL) error {
464+ httpClient, reqURL, err := readinessHTTPClient(hostURL)
465+ if err != nil {
466+ return err
467+ }
468+ return probeHealth(ctx, httpClient, reqURL, hostURL)
469+}
470+
471+// perHostServerDir returns (and creates) the cache directory used for
472+// per-host server state (logs, start.lock, etc.). It mirrors the path
473+// computed in startDetachedServer so both code paths stay in sync.
474+func perHostServerDir() (string, error) {
475+ safeClientHost := safeNameRegexp.ReplaceAllString(clientHost, "_")
476+ chDir := filepath.Join(config.GlobalCacheDir(), "server-"+safeClientHost)
477+ if err := os.MkdirAll(chDir, 0o700); err != nil {
478+ return "", fmt.Errorf("failed to create server working directory: %v", err)
479+ }
480+ return chDir, nil
481+}
482+
418483// serverReadyTimeout returns the total budget for the readiness probe.
419484// Overridable via CRUSH_SERVER_READY_TIMEOUT (parsed as a Go duration).
420485func serverReadyTimeout() time.Duration {
@@ -572,10 +637,9 @@ func startDetachedServer(cmd *cobra.Command) error {
572637 return fmt.Errorf("failed to get executable path: %v", err)
573638 }
574639
575− safeClientHost := safeNameRegexp.ReplaceAllString(clientHost, "_")
576− chDir := filepath.Join(config.GlobalCacheDir(), "server-"+safeClientHost)
577− if err := os.MkdirAll(chDir, 0o700); err != nil {
578− return fmt.Errorf("failed to create server working directory: %v", err)
640+ chDir, err := perHostServerDir()
641+ if err != nil {
642+ return err
579643 }
580644
581645 cmdArgs := []string{"server"}