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.
This commit is contained in:
Kristoffer Dalby
2026-06-17 20:12:01 +00:00
parent d955cd6262
commit 5176dd23a9
+26 -1
View File
@@ -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