From 5176dd23a93fb014b370d93181d853d888e6b616 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Wed, 17 Jun 2026 20:12:01 +0000 Subject: [PATCH] cmd/headscale: wait for the local socket while the server starts A CLI command issued right after startup can beat headscale to binding its unix socket. Retry the dial until the CLI timeout, matching the old blocking gRPC dial, instead of failing on a missing socket file. --- cmd/headscale/cli/client.go | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/cmd/headscale/cli/client.go b/cmd/headscale/cli/client.go index 52a3ccb5..36c97585 100644 --- a/cmd/headscale/cli/client.go +++ b/cmd/headscale/cli/client.go @@ -84,7 +84,7 @@ func localSocketClient(socketPath string) (*apiv1.Client, error) { httpClient := &http.Client{ Transport: &http.Transport{ DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { - return (&net.Dialer{}).DialContext(ctx, "unix", socketPath) + return dialSocketWaiting(ctx, socketPath) }, }, } @@ -93,6 +93,31 @@ func localSocketClient(socketPath string) (*apiv1.Client, error) { return apiv1.NewClient("http://unix", cliToken("local-socket"), apiv1.WithClient(httpClient)) } +// dialSocketWaiting connects to the local unix socket, retrying while it is +// still absent. headscale removes and rebinds the socket during startup, so a +// CLI command issued right after "systemctl start" can beat the server to it. +// This mirrors the old blocking gRPC dial, which retried until the CLI timeout. +func dialSocketWaiting(ctx context.Context, socketPath string) (net.Conn, error) { + var dialer net.Dialer + + for { + conn, err := dialer.DialContext(ctx, "unix", socketPath) + if err == nil { + return conn, nil + } + + if ctx.Err() != nil { + return nil, err + } + + select { + case <-ctx.Done(): + return nil, err + case <-time.After(100 * time.Millisecond): + } + } +} + func remoteClient(address, apiKey string, insecure bool) (*apiv1.Client, error) { if apiKey == "" { return nil, errAPIKeyNotSet