From 4d5a1d970981cc61d56744ff0d28678d6ee84bda Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Thu, 13 Aug 2026 00:24:16 +0000 Subject: [PATCH] Cancel actions on process interrupts --- cmd/weatherreporter/main.go | 11 ++++++- cmd/weatherreporter/main_test.go | 56 ++++++++++++++++++++++++++++++++ docs/cli.md | 4 +++ docs/internal/cli.md | 4 +++ docs/operations.md | 4 +++ 5 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 cmd/weatherreporter/main_test.go diff --git a/cmd/weatherreporter/main.go b/cmd/weatherreporter/main.go index 61d4cd4..c8f6dd6 100644 --- a/cmd/weatherreporter/main.go +++ b/cmd/weatherreporter/main.go @@ -3,14 +3,23 @@ package main import ( "context" "fmt" + "io" "os" + "os/signal" + "syscall" "gitea.maximumdirect.net/eric/weatherreporter/internal/cli" ) func main() { - if err := cli.Run(context.Background(), os.Args[1:], os.Stdout, os.Stderr); err != nil { + if err := runCommand(os.Args[1:], os.Stdout, os.Stderr, cli.Run); err != nil { fmt.Fprintf(os.Stderr, "weatherreporter: %v\n", err) os.Exit(1) } } + +func runCommand(args []string, stdout, stderr io.Writer, runner func(context.Context, []string, io.Writer, io.Writer) error) error { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + return runner(ctx, args, stdout, stderr) +} diff --git a/cmd/weatherreporter/main_test.go b/cmd/weatherreporter/main_test.go new file mode 100644 index 0000000..3a8d525 --- /dev/null +++ b/cmd/weatherreporter/main_test.go @@ -0,0 +1,56 @@ +package main + +import ( + "context" + "errors" + "io" + "os" + "syscall" + "testing" + "time" +) + +func TestRunCommandCancelsActionContextOnSignal(t *testing.T) { + for _, tt := range []struct { + name string + signal os.Signal + }{ + {name: "Interrupt", signal: os.Interrupt}, + {name: "Terminate", signal: syscall.SIGTERM}, + } { + t.Run(tt.name, func(t *testing.T) { + started := make(chan struct{}) + done := make(chan error, 1) + go func() { + done <- runCommand(nil, io.Discard, io.Discard, func(ctx context.Context, _ []string, _, _ io.Writer) error { + close(started) + <-ctx.Done() + return ctx.Err() + }) + }() + + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("runner did not receive an action context") + } + + process, err := os.FindProcess(os.Getpid()) + if err != nil { + t.Fatalf("FindProcess() error = %v", err) + } + if err := process.Signal(tt.signal); err != nil { + t.Fatalf("Signal(%v) error = %v", tt.signal, err) + } + + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("runCommand() error = %v, want context cancellation", err) + } + case <-time.After(time.Second): + t.Fatal("interrupt did not cancel the action context") + } + }) + } +} diff --git a/docs/cli.md b/docs/cli.md index 703a95d..02a2593 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -68,6 +68,10 @@ failure before publication leaves an existing destination unchanged. A notification failure occurs after publication, so the newly written output remains available. +`SIGINT` and `SIGTERM` cancel an active action. Weatherreporter lets that +cancellation reach the action before exiting; when the action has a result, it +emits the usual failed summary and exits nonzero. + Action commands (`generate`, `run`, and `compare`) write a JSON summary to stdout unless `--quiet` is set. `run` also writes compact per-report and batch status lines to stderr. A pre-run error, such as an invalid flag, missing diff --git a/docs/internal/cli.md b/docs/internal/cli.md index 77dbba3..21f1179 100644 --- a/docs/internal/cli.md +++ b/docs/internal/cli.md @@ -4,6 +4,10 @@ The root `--version` flag reports the build version supplied by `internal/buildinfo`. Tagged release builds replace its development default at link time. +The executable derives its action context from `SIGINT` and `SIGTERM` and +passes it to `Runner.Run`. Signal cancellation therefore uses the same action, +summary, and error paths as other context cancellation. + For each `generate` or `run` action, `Runner` constructs one project-owned Promptkit executor after configuration loads. It captures an absolute working directory, resolves only a relative explicit output override against it, and passes the working directory, loaded configuration, resolved override, and any `--llm-debug-dir` request to the app. The raw configured fallback remains in the configuration for app-owned destination selection. `run` uses the same explicit-resolution rule for `--out-dir`. The CLI dispatches only generation and batch actions. It has no persisted-run or inspection dispatch. Summaries include report identity, status, output path, effective profile/backend/model, source warnings, validation, requested debug path, and notification result when available. They intentionally exclude prompt input, raw generated text, render context, endpoints, credentials, and full Distributor payloads. A failed action with a partial result still emits its safe summary before its error is returned. diff --git a/docs/operations.md b/docs/operations.md index bbdbfed..10889fe 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -36,6 +36,10 @@ absolute output path and active profile, backend, model, warnings, validation, debug, and notification information; see the [CLI reference](cli.md) for its exact fields. +`SIGINT` and `SIGTERM` request orderly cancellation of an active action. The +command lets cancellation and related cleanup finish before it exits; use the +usual failed result or error to determine whether an output was published. + ## Batch Outputs And Distributor Notification Run a scheduled batch with an explicit output directory when appropriate: