charmbracelet/crush · error

server shutdown failed: %s

Error message

server shutdown failed: %s

What it means

ShutdownServerIfIdle sends an idle-shutdown control command; this base error is created when the response status is not 200 OK. It is the plain failure case for any unexpected status (e.g. 500), while 409 and 400 are further wrapped with ErrServerBusy and ErrUnsupported sentinel errors. Note this error is returned unwrapped in the default switch branch, so callers should errors.Is-check sentinels, not string-match.

Source

Thrown at internal/client/client.go:139

// variant: a client only ever wants a server replaced, never other
// sessions killed.
//
// A server that declines because it is in use returns an error wrapping
// [ErrServerBusy]. A server too old to know the command returns
// [ErrUnsupported]; it must be left running, since the shutdown request
// it does understand is unconditional and would take its sessions down.
func (c *Client) ShutdownServerIfIdle(ctx context.Context) error {
	rsp, err := c.post(ctx, "/control", nil, jsonBody(proto.ServerControl{
		Command: proto.ServerControlShutdownIfIdle,
	}), nil)
	if err != nil {
		return err
	}
	defer rsp.Body.Close()
	if rsp.StatusCode == http.StatusOK {
		return nil
	}
	failure := fmt.Errorf("server shutdown failed: %s", rsp.Status)
	switch rsp.StatusCode {
	case http.StatusConflict:
		return fmt.Errorf("%w: %w", ErrServerBusy, failure)
	case http.StatusBadRequest:
		// The only way a well-formed control request is rejected as bad
		// is an unknown command, i.e. a server predating this one.
		return fmt.Errorf("%w: %w", ErrUnsupported, failure)
	}
	return failure
}

// ShutdownServer sends the original, unconditional "shutdown" command.
// It exists for backward compatibility with servers that predate
// [ServerControlShutdownIfIdle]: those servers reject the idle-checked
// variant with [ErrUnsupported], so a client that has already verified
// the server is idle (e.g. via [Client.ListWorkspaces]) can fall back to
// this command to replace an old server.
//

View on GitHub (pinned to 7944b8e522)

Solutions

  1. Check the HTTP status embedded in the message to classify the failure.
  2. For 500s, inspect server logs for the shutdown-time failure (DB close, flush errors).
  3. If behind a proxy, hit the server address directly.
  4. If the server is busy the caller should instead see ErrServerBusy (409) — handle that sentinel separately and retry later.
Defensive patterns

Strategy: try-catch

Validate before calling

if err := client.Health(ctx); err != nil {
    return fmt.Errorf("server unhealthy before shutdown attempt: %w", err)
}

Type guard

func IsPlainShutdownFailure(err error) bool {
    return err != nil && !errors.Is(err, ErrServerBusy) && !errors.Is(err, ErrUnsupported) &&
        strings.Contains(err.Error(), "server shutdown failed")
}

Try / catch

if err := client.ShutdownServerIfIdle(ctx); err != nil {
    switch {
    case errors.Is(err, ErrServerBusy):
        return nil
    case errors.Is(err, ErrUnsupported):
        return client.ShutdownServer(ctx)
    default:
        return fmt.Errorf("unexpected shutdown status: %w", err)
    }
}

Prevention

When it happens

Trigger: Calling ShutdownServerIfIdle and receiving any non-200 status other than 409/400 — most commonly a 500 from the server failing to persist state before shutdown, or a 502/503 from a proxy in front of the server.

Common situations: Server bug during shutdown (DB close failure) yielding 500; infrastructure proxy intercepting the control endpoint; server version drift where a new status code appears.

Related errors


AI-assisted analysis of charmbracelet/crush@7944b8e522 (2026-08-29). Data as JSON: /api/errors/b36810b722f59e87. Report an issue: GitHub.