diff --git a/cmd/anfra/commands.go b/cmd/anfra/commands.go index 875b4c4..aaa2068 100644 --- a/cmd/anfra/commands.go +++ b/cmd/anfra/commands.go @@ -1,6 +1,7 @@ package main import ( + "context" "encoding/json" "fmt" "io" @@ -74,7 +75,7 @@ func buildCobraCommand(c app.Command) *cobra.Command { } } } - cmd.RunE = func(_ *cobra.Command, posArgs []string) error { + cmd.RunE = func(runCmd *cobra.Command, posArgs []string) error { args := map[string]any{} for k, v := range strVals { args[k] = *v @@ -91,7 +92,7 @@ func buildCobraCommand(c app.Command) *cobra.Command { if err := applyStdin(c, args); err != nil { return err } - return runCommand(c, args) + return runCommand(runCmd.Context(), c, args) } return cmd } @@ -121,7 +122,7 @@ func applyStdin(c app.Command, args map[string]any) error { // runCommand routes a command to the warm server when one is running for this // repo, otherwise runs it one-shot (spawning only the sidecars it needs). -func runCommand(c app.Command, args map[string]any) error { +func runCommand(ctx context.Context, c app.Command, args map[string]any) error { repoDir, err := os.Getwd() if err != nil { return fmt.Errorf("resolve repo dir: %w", err) @@ -137,14 +138,14 @@ func runCommand(c app.Command, args map[string]any) error { return present(body, contentType) } - return withRepo(func(h hostContext) error { - clients, closeSidecars, err := startNeededSidecars(h, c, args) + return withRepo(ctx, func(ctx context.Context, h hostContext) error { + clients, closeSidecars, err := startNeededSidecars(ctx, h, c, args) if err != nil { return err } defer closeSidecars() - resp, err := app.Dispatch(h.ctx, clients, h.repo, req) + resp, err := app.Dispatch(ctx, clients, h.repo, req) if err != nil { return err } @@ -183,7 +184,7 @@ func present(body []byte, contentType string) error { // startNeededSidecars spawns just the sidecars the command declares it needs // for these args, returning the clients and a single close func (LIFO). -func startNeededSidecars(h hostContext, c app.Command, args map[string]any) (app.Clients, func(), error) { +func startNeededSidecars(ctx context.Context, h hostContext, c app.Command, args map[string]any) (app.Clients, func(), error) { var need app.Sidecars if c.Needs != nil { need = c.Needs(args) @@ -198,7 +199,7 @@ func startNeededSidecars(h hostContext, c app.Command, args map[string]any) (app if need.Node { node := sidecar.NewAnfraNode(h.cfg) - if err := node.Start(h.ctx); err != nil { + if err := node.Start(ctx); err != nil { closeAll() return app.Clients{}, nil, fmt.Errorf("start anfra-node sidecar: %w", err) } @@ -207,7 +208,7 @@ func startNeededSidecars(h hostContext, c app.Command, args map[string]any) (app } if need.CanalQuery { canal := sidecar.NewCanalQuery(h.cfg) - if err := canal.Start(h.ctx); err != nil { + if err := canal.Start(ctx); err != nil { closeAll() return app.Clients{}, nil, fmt.Errorf("start canal-query sidecar: %w", err) } diff --git a/cmd/anfra/host.go b/cmd/anfra/host.go index cd02c0c..00eb5f9 100644 --- a/cmd/anfra/host.go +++ b/cmd/anfra/host.go @@ -13,16 +13,18 @@ import ( // hostContext carries the per-invocation repo + the sidecar Config (with the // host-aggregated log sink) so commands can spawn whichever sidecars they need. +// The context is passed to fn as a parameter (not stored here) so it stays a +// properly-inherited, cancelable value. type hostContext struct { - ctx context.Context repo repo.Repo cfg sidecar.Config } -// withRepo resolves the repo and sets up host-aggregated logging, then -// runs fn. Sidecar lifecycle is the command's choice (some need only anfra-node, -// query execution also needs canal-query). -func withRepo(fn func(h hostContext) error) error { +// withRepo resolves the repo and sets up host-aggregated logging, then runs fn +// with the caller's (signal-cancelable) context and the host context. Sidecar +// lifecycle is the command's choice (some need only anfra-node, query execution +// also needs canal-query). +func withRepo(ctx context.Context, fn func(ctx context.Context, h hostContext) error) error { repoDir, err := os.Getwd() if err != nil { return fmt.Errorf("resolve repo dir: %w", err) @@ -35,8 +37,7 @@ func withRepo(fn func(h hostContext) error) error { } defer lg.Close() - return fn(hostContext{ - ctx: context.Background(), + return fn(ctx, hostContext{ repo: repo, cfg: sidecar.Config{ RepoID: repo.ID, diff --git a/cmd/anfra/main.go b/cmd/anfra/main.go index b9cafd9..36898fc 100644 --- a/cmd/anfra/main.go +++ b/cmd/anfra/main.go @@ -1,9 +1,12 @@ package main import ( + "context" "errors" "fmt" "os" + "os/signal" + "syscall" "github.com/holistics/anfra/internal/meta" "github.com/spf13/cobra" @@ -16,9 +19,15 @@ type exitCodeError struct{ code int } func (e *exitCodeError) Error() string { return fmt.Sprintf("exit code %d", e.code) } func main() { - // ExecuteC returns the command that actually ran, so we get the real - // subcommand name (handles flags/args/aliases) rather than parsing os.Args. - executed, err := newRootCmd().ExecuteC() + // A single signal-cancelable root context, threaded down through cobra so + // Ctrl-C (SIGINT/SIGTERM) cancels in-flight work — an update download, a + // query, or the serve loop. + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + // ExecuteContextC sets that root context and returns the command that actually + // ran, so we get the real subcommand name (handles flags/args/aliases). + executed, err := newRootCmd().ExecuteContextC(ctx) // After the command runs, surface a cached "update available" notice (and, if // opted in, kick a background update). Best-effort; never affects exit status. if executed != nil { diff --git a/cmd/anfra/serve.go b/cmd/anfra/serve.go index a724276..d13e10c 100644 --- a/cmd/anfra/serve.go +++ b/cmd/anfra/serve.go @@ -9,9 +9,7 @@ import ( "net" "net/http" "os" - "os/signal" "path/filepath" - "syscall" "time" "github.com/holistics/anfra/internal/app" @@ -31,14 +29,14 @@ func newServeCmd() *cobra.Command { return &cobra.Command{ Use: "serve", Short: "Run the anfra server: keep sidecars warm and expose POST /call for agents and subsequent CLI calls", - RunE: func(_ *cobra.Command, _ []string) error { - return runServe() + RunE: func(cmd *cobra.Command, _ []string) error { + return runServe(cmd.Context()) }, } } -func runServe() error { - return withRepo(func(h hostContext) error { +func runServe(ctx context.Context) error { + return withRepo(ctx, func(ctx context.Context, h hostContext) error { if isServeRunning(h.repo) { return fmt.Errorf("anfra serve already running for this repo (socket %s)", serveSocketPath(h.repo)) } @@ -49,12 +47,12 @@ func runServe() error { cfg.EnablePooling = true node := sidecar.NewAnfraNode(cfg) - if err := node.Start(h.ctx); err != nil { + if err := node.Start(ctx); err != nil { return fmt.Errorf("start anfra-node sidecar: %w", err) } defer node.Close() canal := sidecar.NewCanalQuery(cfg) - if err := canal.Start(h.ctx); err != nil { + if err := canal.Start(ctx); err != nil { return fmt.Errorf("start canal-query sidecar: %w", err) } defer canal.Close() @@ -72,12 +70,12 @@ func runServe() error { srv := &http.Server{Handler: serveMux(h, clients), ReadHeaderTimeout: 10 * time.Second} go func() { - sig := make(chan os.Signal, 1) - signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) - <-sig - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + <-ctx.Done() // cancelled on SIGINT/SIGTERM by the root context in main + // Drain with a fresh deadline: WithoutCancel keeps ctx's values but drops + // its (already-fired) cancellation, so Shutdown gets the full 5s. + shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) defer cancel() - _ = srv.Shutdown(ctx) + _ = srv.Shutdown(shutdownCtx) }() h.cfg.Logger.Info("serve.listening", "socket", sockPath) @@ -111,11 +109,10 @@ func serveMux(h hostContext, clients app.Clients) http.Handler { // `help` (truthy) → the command's cobra help text, identical to `anfra --help`. if app.IsTruthy(req.Args["help"]) { - // commandHelp only renders help text; it builds the command tree but never - // executes a RunE, so there is no request context to thread into the update - // command's HTTP client (which is why contextcheck is suppressed here). + // commandHelp only builds the command tree to render help text; it never + // executes a RunE, so there's no request context to thread into a command + // body. False positive — the rest of the tree is properly ctx-threaded. text, err := commandHelp(req.Command) //nolint:contextcheck - if err != nil { writeCallError(w, http.StatusNotFound, err.Error()) return diff --git a/cmd/anfra/update.go b/cmd/anfra/update.go index 0d0ca32..b37c948 100644 --- a/cmd/anfra/update.go +++ b/cmd/anfra/update.go @@ -19,19 +19,18 @@ func newUpdateCmd() *cobra.Command { cmd := &cobra.Command{ Use: "update", Short: "Update anfra to the latest release (use --check to only report)", - RunE: func(_ *cobra.Command, _ []string) error { - return runUpdate(checkOnly) + RunE: func(cmd *cobra.Command, _ []string) error { + return runUpdate(cmd.Context(), checkOnly) }, } cmd.Flags().BoolVar(&checkOnly, "check", false, "only check for an update; do not install") return cmd } -func runUpdate(checkOnly bool) error { - // Root at Background (repo convention, see host.go/runServe); timeouts are - // bounded per-request inside the update package (a quick lookup, then a large - // download). - ctx := context.Background() +func runUpdate(ctx context.Context, checkOnly bool) error { + // ctx is the signal-cancelable root from main, so Ctrl-C aborts the download + // (its request is context-aware). Per-request timeouts are bounded inside the + // update package (a quick lookup, then a large download). rel, err := update.Latest(ctx) if err != nil { return err @@ -68,8 +67,8 @@ func newUpdateCheckCmd() *cobra.Command { return &cobra.Command{ Use: "__update-check", Hidden: true, - RunE: func(_ *cobra.Command, _ []string) error { - return update.Refresh(context.Background()) + RunE: func(cmd *cobra.Command, _ []string) error { + return update.Refresh(cmd.Context()) }, } }