Files
Christian RochaandCharm Crush 9b366c36c3 fix(server): probe readiness over HTTP instead of statting the socket
Cold starts of CRUSH_CLIENT_SERVER=1 frequently failed with a stat error
because the spawned server creates the socket file as the last step of
initialization, well after the parent had given up. Replace the stat
polling with a real HTTP probe against /v1/health, treat any 2xx as
ready, and make the total budget overridable via CRUSH_SERVER_READY_TIMEOUT.
Drop the now-redundant CreateWorkspace retry loop.

Co-Authored-By: Charm Crush <crush@charm.land>
2026-05-11 15:57:04 -04:00

205 lines
5.5 KiB
Go

package client
import (
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
stdpath "path"
"path/filepath"
"time"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/proto"
"github.com/charmbracelet/crush/internal/server"
)
// DummyHost is used to satisfy the http.Client's requirement for a URL.
const DummyHost = "api.crush.localhost"
// Client represents an RPC client connected to a Crush server.
type Client struct {
h *http.Client
path string
network string
addr string
}
// DefaultClient creates a new [Client] connected to the default server address.
func DefaultClient(path string) (*Client, error) {
host, err := server.ParseHostURL(server.DefaultHost())
if err != nil {
return nil, err
}
return NewClient(path, host.Scheme, host.Host)
}
// NewClient creates a new [Client] connected to the server at the given
// network and address.
func NewClient(path, network, address string) (*Client, error) {
c := new(Client)
c.path = filepath.Clean(path)
c.network = network
c.addr = address
p := &http.Protocols{}
p.SetHTTP1(true)
p.SetUnencryptedHTTP2(true)
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.Protocols = p
tr.DialContext = c.dialer
if c.network == "npipe" || c.network == "unix" {
tr.DisableCompression = true
}
c.h = &http.Client{
Transport: tr,
Timeout: 0,
}
return c, nil
}
// Path returns the client's workspace filesystem path.
func (c *Client) Path() string {
return c.path
}
// GetGlobalConfig retrieves the server's configuration.
func (c *Client) GetGlobalConfig(ctx context.Context) (*config.Config, error) {
var cfg config.Config
rsp, err := c.get(ctx, "/config", nil, nil)
if err != nil {
return nil, err
}
defer rsp.Body.Close()
if err := json.NewDecoder(rsp.Body).Decode(&cfg); err != nil {
return nil, err
}
return &cfg, nil
}
// Health checks the server's health status.
func (c *Client) Health(ctx context.Context) error {
rsp, err := c.get(ctx, "/health", nil, nil)
if err != nil {
return err
}
defer rsp.Body.Close()
if rsp.StatusCode != http.StatusOK {
return fmt.Errorf("server health check failed: %s", rsp.Status)
}
return nil
}
// VersionInfo retrieves the server's version information.
func (c *Client) VersionInfo(ctx context.Context) (*proto.VersionInfo, error) {
var vi proto.VersionInfo
rsp, err := c.get(ctx, "version", nil, nil)
if err != nil {
return nil, err
}
defer rsp.Body.Close()
if err := json.NewDecoder(rsp.Body).Decode(&vi); err != nil {
return nil, err
}
return &vi, nil
}
// ShutdownServer sends a shutdown request to the server.
func (c *Client) ShutdownServer(ctx context.Context) error {
rsp, err := c.post(ctx, "/control", nil, jsonBody(proto.ServerControl{
Command: "shutdown",
}), nil)
if err != nil {
return err
}
defer rsp.Body.Close()
if rsp.StatusCode != http.StatusOK {
return fmt.Errorf("server shutdown failed: %s", rsp.Status)
}
return nil
}
// Dial opens a connection to the server using the same scheme-aware
// logic the client uses for its HTTP transport. Exposed so callers can
// reuse the dialer when they need to construct sibling HTTP transports
// (e.g. a readiness probe in the CLI).
func (c *Client) Dial(ctx context.Context, network, address string) (net.Conn, error) {
return c.dialer(ctx, network, address)
}
func (c *Client) dialer(ctx context.Context, network, address string) (net.Conn, error) {
d := net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}
switch c.network {
case "npipe":
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
return dialPipeContext(ctx, c.addr)
case "unix":
return d.DialContext(ctx, "unix", c.addr)
default:
return d.DialContext(ctx, network, address)
}
}
func (c *Client) get(ctx context.Context, path string, query url.Values, headers http.Header) (*http.Response, error) {
return c.sendReq(ctx, http.MethodGet, path, query, nil, headers)
}
func (c *Client) post(ctx context.Context, path string, query url.Values, body io.Reader, headers http.Header) (*http.Response, error) {
return c.sendReq(ctx, http.MethodPost, path, query, body, headers)
}
func (c *Client) delete(ctx context.Context, path string, query url.Values, headers http.Header) (*http.Response, error) {
return c.sendReq(ctx, http.MethodDelete, path, query, nil, headers)
}
func (c *Client) put(ctx context.Context, path string, query url.Values, body io.Reader, headers http.Header) (*http.Response, error) {
return c.sendReq(ctx, http.MethodPut, path, query, body, headers)
}
func (c *Client) sendReq(ctx context.Context, method, path string, query url.Values, body io.Reader, headers http.Header) (*http.Response, error) {
url := (&url.URL{
Path: stdpath.Join("/v1", path),
RawQuery: query.Encode(),
}).String()
req, err := c.buildReq(ctx, method, url, body, headers)
if err != nil {
return nil, err
}
rsp, err := c.h.Do(req)
if err != nil {
return nil, err
}
return rsp, nil
}
func (c *Client) buildReq(ctx context.Context, method, url string, body io.Reader, headers http.Header) (*http.Request, error) {
r, err := http.NewRequestWithContext(ctx, method, url, body)
if err != nil {
return nil, err
}
for k, v := range headers {
r.Header[http.CanonicalHeaderKey(k)] = v
}
r.URL.Scheme = "http"
r.URL.Host = c.addr
if c.network == "npipe" || c.network == "unix" {
r.Host = DummyHost
}
if body != nil && r.Header.Get("Content-Type") == "" {
r.Header.Set("Content-Type", "text/plain")
}
return r, nil
}