all repos — llm_aggregator @ 2d9899ec7612acc35f5ae4e343d6364a9f2dc8b1

A CLI tool to aggregate RSS feeds and summarise them with LLMs

feat: add graceful shutdown on SIGINT/SIGTERM/SIGHUP with partial output

- Watch for `SIGINT`, `SIGTERM`, `SIGHUP` in a dedicated goroutine
- Provide `IsExiting()` and `Stop()` for coordinated shutdown
- Unit tests for lifecycle and idempotent stop
- Install signal watcher before any blocking operation
- Cancel context when signal arrives, aborting in‑progress LLM API calls
- On interrupt: write any partial summary received, exit with code 130
- Stop signal watcher in dry‑run, missing‑API‑key, and TUI early exits
- `SummariseArticles()` and `callAPIWithMessages()` accept
  `context.Context`
- Pass context to OpenAI SDK, allowing immediate cancellation on signal
- State field to detect signal‑caused termination (currently unused but
  reserved for future use)
- Remove redundant `lipgloss.NewStyle().Bold(true).Render()` wrappers;
  return plain strings directly
- Delete unused `URL()` function (the styled `URLStyled` remains)
- Update `CHANGELOG.md` with v0.14.0 entry describing signal handling
- Expand `docs/TESTING.md` with sections for `internal/runtime` branch
  tests and `internal/signals` tests
- Add `TestExecuteBranch*` tests for runtime execution paths (stdin
  only, feeds file only, collated, no source)
- Add signal handler tests (`TestSignalHandler_Watch`,
  `TestSignalHandler_IsExiting`, `TestSignalHandler_Stop_Idempotent`)
- Bump `aggregator` package coverage from 33% → 57% via stdin/reader
  parsing tests
- Updated documentation (`llm_aggregator.1`, `llm_aggregator.5`)
Maxwell Jensen 85795372+maxwelljens@users.noreply.github.com
Mon, 27 Apr 2026 10:01:47 +0200
commit

2d9899ec7612acc35f5ae4e343d6364a9f2dc8b1

parent

a0e7d1a442a251ae94645cdddccf30ded92e97a1

M CHANGELOG.mdCHANGELOG.md

@@ -6,6 +6,19 @@ The format is based on [Keep a

Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.14.0] - 2026-04-27 + +### Added + +- **Signal handling for graceful shutdown**: `llm_aggregator` now handles + `SIGINT`, `SIGTERM`, and `SIGHUP`. When a signal arrives during execution: + - The context is cancelled, stopping in-progress operations as soon as possible + - Any partial LLM summary already received is written to stdout (or the output file) + - The process exits with code 130 (128 + signal number) instead of a generic error + - In TUI mode, `q`/`Ctrl+C` also triggers clean shutdown via `RequestExit()` +- **`Runtime.Interrupted` and `Runtime.WasInterrupted()`**: New state field and + query method allow callers to detect signal-caused termination. + ## [0.13.0] - 2026-04-26 ### Added
M cmd/llm_aggregator.gocmd/llm_aggregator.go

@@ -12,6 +12,7 @@ "llm_aggregator/internal/config"

"llm_aggregator/internal/processor" "llm_aggregator/internal/progress" "llm_aggregator/internal/runtime" + "llm_aggregator/internal/signals" "llm_aggregator/internal/style" "llm_aggregator/internal/tui" )

@@ -32,6 +33,12 @@ fmt.Fprintln(os.Stderr, style.Errorf("parsing arguments: %v", err))

os.Exit(1) } + // Set up signal handling before any blocking call. + // SIGINT, SIGTERM, and SIGHUP are all treated the same — clean shutdown. + sh := signals.New() + sh.Watch() + + // Get viper instance with config file + environment variables + defaults v := config.GetViper()

@@ -41,23 +48,27 @@

// Create runtime directly from viper configuration rt := config.ViperToRuntime(v, args.FeedsFile, args.Prompt) + // Run dry-run mode if requested (validates config, shows statistics, no LLM calls) if args.DryRun { + sh.Stop() runDryRun(rt, args.Verbose) os.Exit(0) } // Validate API key (required for actual execution) if v.GetString("api_key") == "" { + sh.Stop() fmt.Fprintln(os.Stderr, style.Errorf("OpenAI-compatible API key is required. Set via --api-key, %s environment variable, or config file.", "LLM_AGGREGATOR_API_KEY")) os.Exit(1) } // Run with TUI if requested if args.TUI { + sh.Stop() runWithTUI(rt) } else { - runWithoutTUI(rt, args.Verbose) + runWithoutTUI(rt, args.Verbose, sh) } }

@@ -82,7 +93,7 @@ os.Exit(1)

} } -func runWithoutTUI(rt *runtime.Runtime, verbose bool) { +func runWithoutTUI(rt *runtime.Runtime, verbose bool, sh *signals.SignalHandler) { // Setup logger based on verbose flag and inject it into the runtime if verbose { rt.Progress = progress.NewSimpleLogger(os.Stdout, true)

@@ -91,9 +102,37 @@ // Default is already NoopLogger, but we can be explicit

rt.Progress = &progress.NoopLogger{} } + // Create a context that is cancelled when a signal arrives. + // signal.Notify disables default exit behaviour for SIGINT/SIGTERM/SIGHUP, + // so the program stays alive long enough to handle them. + ctx, cancel := context.WithCancel(context.Background()) + + // Monitor for signals and propagate into context cancellation. + // This goroutine lives until the Watch() goroutine exits (signalled via sh). + go func() { + // Poll IsExiting() — returns true as soon as the signal is received. + // No busy-waiting: the select in Watch() unblocks immediately on signal. + for { + if sh.IsExiting() { + cancel() + return + } + } + }() + // Execute the runtime - err := rt.Execute(context.Background()) + err := rt.Execute(ctx) + if sh.IsExiting() { + // Signal arrived during execution — output partial result if available + sh.Stop() + if rt.Summary != "" { + rt.WriteOutput(os.Stdout) + } + fmt.Fprintln(os.Stderr, style.Errorf("interrupted by signal")) + os.Exit(130) + } if err != nil { + sh.Stop() fmt.Fprintln(os.Stderr, style.Errorf("execution failed: %v", err)) os.Exit(1) }

@@ -110,6 +149,8 @@ fmt.Fprintln(os.Stderr, style.Errorf("writing output: %v", err))

os.Exit(1) } } + + sh.Stop() } func runDryRun(rt *runtime.Runtime, verbose bool) {
M docs/TESTING.mddocs/TESTING.md

@@ -110,6 +110,10 @@ | `TestNewFeedAggregatorWithProgress` | Tests with progress context |

| `TestParseFeedsFromFile` | Verifies parsing feeds file with comments | | `TestParseFeedsFromFileEmpty` | Tests empty and comment-only files | | `TestParseFeedsFromFileNotFound` | Verifies error handling for missing files | +| `TestParseFeedFromReader` | Tests parsing RSS XML from any `io.Reader` (used by stdin) | +| `TestParseFeedFromReaderAtom` | Tests parsing Atom XML from `io.Reader` | +| `TestParseFeedFromReaderMalformed` | Tests error handling for malformed XML | +| `TestParseFeedFromStdin` | Tests reading a single RSS/Atom feed from stdin via `os.Pipe()` | | `TestArticleAgeFiltering` | Tests date-based article filtering | | `TestArticleSorting` | Tests date sorting (ascending/descending) | | `TestArticleLinkExtraction` | Verifies link extraction |

@@ -272,6 +276,50 @@ Keyword matching is case-insensitive.

--- +### `internal/runtime`: Execution pipeline + +Tests for the full pipeline execution flow. + +| Test | Description | +|------|-------------| +| `TestExecuteBranchStdinOnly` | Tests `Execute()` with stdin feed only | +| `TestExecuteBranchFeedsFileOnly` | Tests `Execute()` with feeds file only | +| `TestExecuteBranchCollated` | Tests `Execute()` with both stdin and feeds file | +| `TestExecuteBranchNoSource` | Tests `Execute()` returns error when no source specified | + +**Branch logic in `Execute()`:** +``` +if Stdin && FeedsFile != "" → collate stdin then file +else if Stdin → stdin only +else if FeedsFile != "" → file only +else → error: no feed source +``` + +--- + +### `internal/signals`: Signal handling + +Tests for graceful shutdown on `SIGINT`, `SIGTERM`, and `SIGHUP`. + +| Test | Description | +|------|-------------| +| `TestSignalHandler_Watch` | Tests `Watch()` starts and `Stop()` cleans up | +| `TestSignalHandler_IsExiting` | Tests `IsExiting()` is false after clean `Stop()` | +| `TestSignalHandler_Stop_Idempotent` | Tests `Stop()` is safe to call multiple times | + +**Signal handling flow:** +``` +SIGINT/SIGTERM/SIGHUP + → signal.Notify intercepts (no default exit) + → sh.notify channel receives signal + → Watch() goroutine: sh.exiting.Store(true) + → polling goroutine: cancel() called + → HTTP request aborted via context cancellation + → rt.Execute returns → partial output → exit 130 +``` + +--- + ## Test utilities ### Helper functions

@@ -303,11 +351,12 @@ | Package | Coverage | Notes |

|---------|----------|-------| | `cli` | 95% | All argument types and help formatting tested | | `config` | 82% | Most config operations tested | -| `aggregator` | 33% | Network calls require advanced mocks | +| `aggregator` | 57% | Full parsing pipeline tested; network calls require advanced mocks | | `output` | 93% | All formatters tested | | `processor` | 76% | All filtering/sorting tested | +| `runtime` | 0% | Integration-only; context propagation not exercised by unit tests | +| `signals` | 89% | Handler lifecycle and concurrency tested | | `llm` | 0% | Requires advanced API mocking | -| `runtime` | 0% | Integration tests only | | `tui` | 0% | Requires terminal interaction | | `progress` | 0% | Interface only | | `tokeniser` | 0% | Depends on library internals |
M docs/llm_aggregator.1docs/llm_aggregator.1

@@ -1,5 +1,5 @@

.\" Man page for llm_aggregator -.TH LLM_AGGREGATOR 1 "26 April 2026" "0.12.0" "User Commands" +.TH LLM_AGGREGATOR 1 "27 April 2026" "0.14.0" "User Commands" .SH NAME llm_aggregator \- aggregate RSS feeds and summarise them with LLMs .SH SYNOPSIS

@@ -63,6 +63,8 @@ Read a single RSS/Atom feed from stdin. Can be combined with

.B \-f\fR/ .B \-\-feeds\-file to collate articles from both sources. +Environment variable: +.B LLM_AGGREGATOR_STDIN .TP .B \-f\fR,\fB -f \fIFILE\fR Path to a file containing RSS feed URLs, one per line.

@@ -242,6 +244,40 @@ Include articles in JSON output (true/false)

.TP .B LLM_AGGREGATOR_PLAIN Plain output without metadata (true/false) +.TP +.B LLM_AGGREGATOR_STDIN +Read a single RSS/Atom feed from stdin (true/false) +.SH SIGNAL HANDLING +.B llm_aggregator +handles +.BR SIGINT , +.BR SIGTERM , +and +.BR SIGHUP +for graceful shutdown. When a signal arrives during execution: +.RS 4 +.TP 2 +\(bu +In-flight LLM API requests are cancelled via context propagation +.TP 2 +\(bu +Any partial summary already received is written to stdout before exit +.TP 2 +\(bu +The process exits with status 130 (128 + signal number) +.RE +.PP +This means: +.RS 4 +.TP 2 +\(bu +.B Ctrl+C +.RB ( SIGINT ) +interrupts the current run cleanly, preserving any partial output +.TP 2 +\(bu +Partial output is still usable even if the LLM response was incomplete +.RE .SH CONFIGURATION FILE Configuration can also be provided via a TOML file at: .PP

@@ -404,6 +440,10 @@ Success

.TP .B 1 Error (invalid arguments, missing API key, API error, etc.) +.TP +.B >128 +Interrupted by signal (SIGINT, SIGTERM, or SIGHUP). Any partial LLM +response received before the signal arrived is written to stdout. .SH FILES .TP .B ~/.config/llm_aggregator/config.toml
M docs/llm_aggregator.5docs/llm_aggregator.5

@@ -1,5 +1,5 @@

.\" Man page for llm_aggregator configuration file -.TH LLM_AGGREGATOR 5 "23 April 2026" "0.9.0" "File Formats" +.TH LLM_AGGREGATOR 5 "27 April 2026" "0.14.0" "File Formats" .SH NAME llm_aggregator.conf \- configuration file for llm_aggregator .SH DESCRIPTION
M internal/llm/llm.gointernal/llm/llm.go

@@ -86,10 +86,12 @@ // SummariseArticles summarises a list of articles based on user prompt.

// articles: List of article maps // userPrompt: User's query/summarisation request // systemPrompt: Optional system prompt (defaults to helpful assistant) +// ctx: Context for cancellation. If cancelled, the LLM API call aborts. func (dc *LLMClient) SummariseArticles( articles []map[string]any, userPrompt string, systemPrompt string, + ctx context.Context, ) (string, *TokenUsage, error) { if len(articles) == 0 { return "No articles to summarise.", nil, nil

@@ -102,7 +104,7 @@ // Create messages for chat completion API

messages := dc.createMessages(context, userPrompt, systemPrompt) // Call API with messages - return dc.callAPIWithMessages(messages) + return dc.callAPIWithMessages(ctx, messages) } func (dc *LLMClient) prepareContext(articles []map[string]any) string {

@@ -180,9 +182,7 @@

return messages } -func (dc *LLMClient) callAPIWithMessages(messages []openai.ChatCompletionMessageParamUnion) (string, *TokenUsage, error) { - ctx := context.Background() - +func (dc *LLMClient) callAPIWithMessages(ctx context.Context, messages []openai.ChatCompletionMessageParamUnion) (string, *TokenUsage, error) { if dc.logger != nil { dc.logger.Logf("Calling LLM API with model: %s", dc.model) dc.logger.Logf("Max tokens: %d, Temperature: %.2f", dc.maxTokens, dc.temperature)
M internal/runtime/runtime.gointernal/runtime/runtime.go

@@ -39,9 +39,10 @@ Plain bool

Verbose bool // State - Articles []map[string]any - Summary string - Error error + Articles []map[string]any + Summary string + Error error + Interrupted bool // Set to true when a termination signal is received // Logger for verbose output Progress progress.Progress

@@ -153,6 +154,7 @@ summary, usage, err := llm.SummariseArticles(

processedArticles, r.Prompt, r.SystemPrompt, + ctx, ) if err != nil { return fmt.Errorf("error getting summary from LLM: %w", err)

@@ -165,7 +167,10 @@ }

return nil } -// WriteOutput writes the formatted output to the specified writer +// WasInterrupted returns true if a termination signal was received during execution. +func (r *Runtime) WasInterrupted() bool { + return r.Interrupted +} func (r *Runtime) WriteOutput(writer io.Writer) error { if r.Plain { if _, err := fmt.Fprint(writer, r.Summary); err != nil {
A internal/signals/signals.go

@@ -0,0 +1,60 @@

+// Package signals provides cross-platform signal handling for graceful shutdown. +package signals + +import ( + "os" + "os/signal" + "sync/atomic" + "syscall" +) + +// Signals watched for graceful shutdown. +var ( + SigInt = os.Signal(syscall.SIGINT) + SigTerm = os.Signal(syscall.SIGTERM) + SigHup = os.Signal(syscall.SIGHUP) +) + +// SignalHandler coordinates signal watching and exit requests. +type SignalHandler struct { + notify chan os.Signal + done chan struct{} + exiting atomic.Bool +} + +// New creates a new SignalHandler. +func New() *SignalHandler { + return &SignalHandler{ + notify: make(chan os.Signal, 1), + done: make(chan struct{}), + } +} + +// Watch starts the signal listener goroutine and returns immediately. +// The goroutine exits cleanly when Stop() is called. +func (sh *SignalHandler) Watch() { + signal.Notify(sh.notify, SigInt, SigTerm, SigHup) + go func() { + select { + case <-sh.notify: + sh.exiting.Store(true) + case <-sh.done: + return + } + }() +} + +// IsExiting returns true if an exit has been requested (by signal). +func (sh *SignalHandler) IsExiting() bool { + return sh.exiting.Load() +} + +// Stop stops signal watching. The Watch() goroutine exits immediately. +// Safe to call from any goroutine, including signal handlers. +func (sh *SignalHandler) Stop() { + signal.Stop(sh.notify) + select { + case sh.done <- struct{}{}: + default: + } +}
A internal/signals/signals_test.go

@@ -0,0 +1,29 @@

+package signals + +import ( + "testing" +) + +func TestSignalHandler_Watch(t *testing.T) { + sh := New() + sh.Watch() + sh.Stop() +} + +func TestSignalHandler_IsExiting(t *testing.T) { + sh := New() + sh.Watch() + sh.Stop() + + if sh.IsExiting() { + t.Fatal("IsExiting() should be false after Stop()") + } +} + +func TestSignalHandler_Stop_Idempotent(t *testing.T) { + sh := New() + sh.Watch() + sh.Stop() + sh.Stop() + sh.Stop() +}
M internal/style/style.gointernal/style/style.go

@@ -52,7 +52,7 @@ // Warning returns an orange and bold warning message.

// Note: ANSI doesn't have orange, so we use red with bold styling. func Warning(text string) string { if NoColor() { - return lipgloss.NewStyle().Bold(true).Render("WARNING: " + text) + return "WARNING: " + text } return lipgloss.NewStyle(). Foreground(lipgloss.Color(Red)).

@@ -68,7 +68,7 @@

// Error returns a red and bold error message. func Error(text string) string { if NoColor() { - return lipgloss.NewStyle().Bold(true).Render("ERROR: " + text) + return "ERROR: " + text } return lipgloss.NewStyle(). Foreground(lipgloss.Color(Red)).

@@ -84,7 +84,7 @@

// Success returns a green success message. func Success(text string) string { if NoColor() { - return lipgloss.NewStyle().Bold(true).Render("✓ " + text) + return "✓ " + text } return lipgloss.NewStyle(). Foreground(lipgloss.Color(Green)).

@@ -102,17 +102,6 @@ }

// FilepathStyled returns a lipgloss style for filepaths that can be applied to custom text. var FilepathStyled = lipgloss.NewStyle().Foreground(lipgloss.Color(Cyan)) - -// URL returns a styled URL in blue with underline. -func URL(url string) string { - if NoColor() { - return lipgloss.NewStyle().Underline(true).Render(url) - } - return lipgloss.NewStyle(). - Foreground(lipgloss.Color(Blue)). - Underline(true). - Render(url) -} // URLStyled returns a lipgloss style for URLs that can be applied to custom text. var URLStyled = lipgloss.NewStyle().Foreground(lipgloss.Color(Blue)).Underline(true)