Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ Two ways to have it. `--runtime docker` pulls the published image, and is what w

Both put the index in the same place, `$XDG_DATA_HOME/sourceant`, so it does not matter which one indexed it. The container runs as whoever installed, so what it writes there belongs to them.

`sourceant ui` starts the agent and opens the view.
`sourceant ui` starts the agent and opens the view. `sourceant stop` shuts down the agent and its core without removing the index or configuration. Stopping requires an agent with stop support.

| Variable | Default | Meaning |
|---|---|---|
Expand All @@ -59,6 +59,7 @@ Both put the index in the same place, `$XDG_DATA_HOME/sourceant`, so it does not
| Command | What it does |
|---|---|
| `sourceant setup` | Put the agent and a core on this machine |
| `sourceant stop` | Stop the agent and its Python core or Docker container |
| `sourceant status` | Whether the agent and the indexer are running |
| `sourceant repos` | Repositories indexed on this machine |
| `sourceant graph <repository>` | What the indexer found in one of them |
Expand Down
34 changes: 34 additions & 0 deletions internal/agent/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ package agent
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"syscall"
"time"
)

Expand Down Expand Up @@ -80,6 +82,11 @@ func (e *Unreachable) Error() string {

func (e *Unreachable) Unwrap() error { return e.Cause }

func IsConnectionRefused(err error) bool {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exported functions should have a doc comment explaining their contract, especially since this is a new public helper used by the command package.

Suggested change
func IsConnectionRefused(err error) bool {
// IsConnectionRefused reports whether err represents an agent that is not
// listening because the connection was refused.
func IsConnectionRefused(err error) bool {
var unreachable *Unreachable
return errors.As(err, &unreachable) && errors.Is(unreachable.Cause, syscall.ECONNREFUSED)
}

var unreachable *Unreachable
return errors.As(err, &unreachable) && errors.Is(unreachable.Cause, syscall.ECONNREFUSED)
}

// Client talks to one agent.
type Client struct {
baseURL string
Expand Down Expand Up @@ -169,3 +176,30 @@ func detail(body []byte) string {
}
return strings.TrimSpace(string(body))
}

func (c *Client) Stop(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/stop", nil)
if err != nil {
return err
}
req.Header.Set("X-Sourceant-Client", "cli")
req.Header.Set("Accept", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return &Unreachable{BaseURL: c.baseURL, Cause: err}
}
defer func() { _ = resp.Body.Close() }()
switch resp.StatusCode {
case http.StatusNoContent:
return nil
case http.StatusOK, http.StatusNotFound, http.StatusMethodNotAllowed:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment only explains the 200 case, but this branch also groups 404 and 405. Update it to describe all three unsupported statuses so future readers don't wonder why NotFound/MethodNotAllowed are handled here.

Suggested change
case http.StatusOK, http.StatusNotFound, http.StatusMethodNotAllowed:
case http.StatusOK, http.StatusNotFound, http.StatusMethodNotAllowed:
// Only 204 acknowledges shutdown; 200/404/405 mean this agent does not support stop.

// Only 204 acknowledges shutdown; a generic 200 does not confirm it.
return &Error{StatusCode: resp.StatusCode, Detail: "this agent does not support stop; update it with sourceant setup"}
default:
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
return &Error{StatusCode: resp.StatusCode, Detail: detail(body)}
}
}
1 change: 1 addition & 0 deletions internal/command/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ func Run(args []string, stdout, stderr io.Writer) int {
root.PersistentFlags().BoolVar(&opts.asJSON, "json", false, "Print the agent's answer as JSON")

root.AddCommand(
stopCommand(opts),
setupCommand(),
statusCommand(opts),
reposCommand(opts),
Expand Down
27 changes: 27 additions & 0 deletions internal/command/stop.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package command

import (
"fmt"

"github.com/sourceant/cli/internal/agent"
"github.com/spf13/cobra"
)

func stopCommand(opts *options) *cobra.Command {
return &cobra.Command{
Use: "stop",
Short: "Stop the agent and its core",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
if err := opts.client().Stop(cmd.Context()); err != nil {
if agent.IsConnectionRefused(err) {
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "SourceAnt is already stopped.")
return nil
}
return fmt.Errorf("could not stop SourceAnt: %w", err)
}
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Stopped SourceAnt.")
return nil
},
}
}
60 changes: 60 additions & 0 deletions internal/command/stop_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package command

import (
"bytes"
"net/http"
"net/http/httptest"
"strings"
"testing"
)

func TestStopCommand(t *testing.T) {
for _, status := range []int{http.StatusNoContent, http.StatusOK, http.StatusNotFound, http.StatusMethodNotAllowed, http.StatusInternalServerError} {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" || r.URL.Path != "/api/stop" || r.Header.Get("X-Sourceant-Client") != "cli" || r.Header.Get("Accept") != "application/json" {
t.Errorf("unexpected stop request: %s %s", r.Method, r.URL)
}
w.WriteHeader(status)
}))
var out, stderr bytes.Buffer
code := Run([]string{"--agent", server.URL, "stop"}, &out, &stderr)
server.Close()
if (code == 0) != (status == http.StatusNoContent) {
Comment thread
nfebe marked this conversation as resolved.
t.Fatalf("status=%d code=%d: %s", status, code, stderr.String())
}
if status == http.StatusOK || status == http.StatusNotFound || status == http.StatusMethodNotAllowed {
if !strings.Contains(stderr.String(), "this agent does not support stop; update it with sourceant setup") {
t.Fatal(stderr.String())
}
}
if code == 0 && !strings.Contains(out.String(), "Stopped SourceAnt.") {
t.Fatal(out.String())
}
}
}

func TestStopAlreadyStopped(t *testing.T) {
server := httptest.NewServer(http.NotFoundHandler())
server.Close()
var out, stderr bytes.Buffer
if Run([]string{"--agent", server.URL, "stop"}, &out, &stderr) != 0 {
t.Fatal(stderr.String())
}
if !strings.Contains(out.String(), "already stopped") {
t.Fatal(out.String())
}
}

func TestStopTimeoutIsNotAlreadyStopped(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
<-r.Context().Done()
}))
defer server.Close()
var out, stderr bytes.Buffer
if Run([]string{"--agent", server.URL, "--timeout", "20ms", "stop"}, &out, &stderr) == 0 {
t.Fatal("a timed-out stop request succeeded")
}
if strings.Contains(out.String(), "already stopped") || stderr.Len() == 0 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checking that stderr is non-empty is weak. Assert the actual error message so the test fails with a clear diagnostic if the wrong error path is taken (for example, if a future change prints "already stopped" to stderr instead of stdout).

Suggested change
if strings.Contains(out.String(), "already stopped") || stderr.Len() == 0 {
if strings.Contains(out.String(), "already stopped") || !strings.Contains(stderr.String(), "could not stop SourceAnt") {
t.Fatalf("stdout=%s stderr=%s", out.String(), stderr.String())
}

t.Fatalf("stdout=%s stderr=%s", out.String(), stderr.String())
}
}
Loading