• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

umputun / ralphex / 21918265588

11 Feb 2026 06:42PM UTC coverage: 80.954% (-0.1%) from 81.055%
21918265588

push

github

umputun
fix: improve Ctrl+C (SIGINT) handling for immediate response

- Make ClaudeExecutor.parseStream context-aware with ctx.Done() check
  on each scan iteration (matching codex/custom executor pattern)
- Replace all time.Sleep calls in runner loops with sleepWithContext
  that uses select on timer and ctx.Done() for immediate cancellation
- Add startInterruptWatcher goroutine for instant "interrupting..."
  feedback on Ctrl+C, with cleanup to prevent goroutine leaks
- Add regression test verifying prompt exit on context cancellation
  during iteration delay

32 of 47 new or added lines in 3 files covered. (68.09%)

5415 of 6689 relevant lines covered (80.95%)

195.79 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

91.82
/pkg/executor/executor.go
1
// Package executor provides CLI execution for Claude and Codex tools.
2
package executor
3

4
import (
5
        "bufio"
6
        "context"
7
        "encoding/json"
8
        "fmt"
9
        "io"
10
        "os"
11
        "os/exec"
12
        "strings"
13

14
        "github.com/umputun/ralphex/pkg/status"
15
)
16

17
//go:generate moq -out mocks/command_runner.go -pkg mocks -skip-ensure -fmt goimports . CommandRunner
18

19
// MaxScannerBuffer is the maximum buffer size for bufio.Scanner.
20
// set to 64MB to handle large outputs (e.g., diffs of large JSON files).
21
const MaxScannerBuffer = 64 * 1024 * 1024
22

23
// Result holds execution result with output and detected signal.
24
type Result struct {
25
        Output string // accumulated text output
26
        Signal string // detected signal (COMPLETED, FAILED, etc.) or empty
27
        Error  error  // execution error if any
28
}
29

30
// PatternMatchError is returned when a configured error pattern is detected in output.
31
type PatternMatchError struct {
32
        Pattern string // the pattern that matched
33
        HelpCmd string // command to run for more information (e.g., "claude /usage")
34
}
35

36
func (e *PatternMatchError) Error() string {
1✔
37
        return fmt.Sprintf("detected error pattern: %q", e.Pattern)
1✔
38
}
1✔
39

40
// CommandRunner abstracts command execution for testing.
41
// Returns an io.Reader for streaming output and a wait function for completion.
42
type CommandRunner interface {
43
        Run(ctx context.Context, name string, args ...string) (output io.Reader, wait func() error, err error)
44
}
45

46
// execClaudeRunner is the default command runner using os/exec.
47
type execClaudeRunner struct{}
48

49
func (r *execClaudeRunner) Run(ctx context.Context, name string, args ...string) (io.Reader, func() error, error) {
2✔
50
        // check context before starting to avoid spawning a process that will be immediately killed
2✔
51
        if err := ctx.Err(); err != nil {
2✔
52
                return nil, nil, fmt.Errorf("context already canceled: %w", err)
×
53
        }
×
54

55
        // use exec.Command (not CommandContext) because we handle cancellation ourselves
56
        // to ensure the entire process group is killed, not just the direct child
57
        cmd := exec.Command(name, args...) //nolint:noctx // intentional: we handle context cancellation via process group kill
2✔
58

2✔
59
        // filter out ANTHROPIC_API_KEY from environment (claude uses different auth)
2✔
60
        cmd.Env = filterEnv(os.Environ(), "ANTHROPIC_API_KEY")
2✔
61

2✔
62
        // create new process group so we can kill all descendants on cleanup
2✔
63
        setupProcessGroup(cmd)
2✔
64

2✔
65
        stdout, err := cmd.StdoutPipe()
2✔
66
        if err != nil {
2✔
67
                return nil, nil, fmt.Errorf("create stdout pipe: %w", err)
×
68
        }
×
69
        // merge stderr into stdout like python's stderr=subprocess.STDOUT
70
        cmd.Stderr = cmd.Stdout
2✔
71
        if err := cmd.Start(); err != nil {
2✔
72
                return nil, nil, fmt.Errorf("start command: %w", err)
×
73
        }
×
74

75
        // setup process group cleanup with graceful shutdown on context cancellation
76
        cleanup := newProcessGroupCleanup(cmd, ctx.Done())
2✔
77

2✔
78
        return stdout, cleanup.Wait, nil
2✔
79
}
80

81
// splitArgs splits a space-separated argument string into a slice.
82
// handles quoted strings (both single and double quotes).
83
func splitArgs(s string) []string {
11✔
84
        var args []string
11✔
85
        var current strings.Builder
11✔
86
        var inQuote rune
11✔
87
        var escaped bool
11✔
88

11✔
89
        for _, r := range s {
252✔
90
                if escaped {
243✔
91
                        current.WriteRune(r)
2✔
92
                        escaped = false
2✔
93
                        continue
2✔
94
                }
95

96
                if r == '\\' {
241✔
97
                        escaped = true
2✔
98
                        continue
2✔
99
                }
100

101
                if r == '"' || r == '\'' {
245✔
102
                        switch { //nolint:staticcheck // cannot use tagged switch because we compare with both inQuote and r
8✔
103
                        case inQuote == 0:
4✔
104
                                inQuote = r
4✔
105
                        case inQuote == r:
4✔
106
                                inQuote = 0
4✔
107
                        default:
×
108
                                current.WriteRune(r)
×
109
                        }
110
                        continue
8✔
111
                }
112

113
                if r == ' ' && inQuote == 0 {
249✔
114
                        if current.Len() > 0 {
35✔
115
                                args = append(args, current.String())
15✔
116
                                current.Reset()
15✔
117
                        }
15✔
118
                        continue
20✔
119
                }
120

121
                current.WriteRune(r)
209✔
122
        }
123

124
        if current.Len() > 0 {
20✔
125
                args = append(args, current.String())
9✔
126
        }
9✔
127

128
        return args
11✔
129
}
130

131
// filterEnv returns a copy of env with specified keys removed.
132
func filterEnv(env []string, keysToRemove ...string) []string {
7✔
133
        result := make([]string, 0, len(env))
7✔
134
        for _, e := range env {
243✔
135
                skip := false
236✔
136
                for _, key := range keysToRemove {
474✔
137
                        if strings.HasPrefix(e, key+"=") {
242✔
138
                                skip = true
4✔
139
                                break
4✔
140
                        }
141
                }
142
                if !skip {
468✔
143
                        result = append(result, e)
232✔
144
                }
232✔
145
        }
146
        return result
7✔
147
}
148

149
// streamEvent represents a JSON event from claude CLI stream output.
150
type streamEvent struct {
151
        Type    string `json:"type"`
152
        Message struct {
153
                Content []struct {
154
                        Type string `json:"type"`
155
                        Text string `json:"text"`
156
                } `json:"content"`
157
        } `json:"message"`
158
        ContentBlock struct {
159
                Type string `json:"type"`
160
                Text string `json:"text"`
161
        } `json:"content_block"`
162
        Delta struct {
163
                Type string `json:"type"`
164
                Text string `json:"text"`
165
        } `json:"delta"`
166
        Result json.RawMessage `json:"result"` // can be string or object with "output" field
167
}
168

169
// ClaudeExecutor runs claude CLI commands with streaming JSON parsing.
170
type ClaudeExecutor struct {
171
        Command       string            // command to execute, defaults to "claude"
172
        Args          string            // additional arguments (space-separated), defaults to standard args
173
        OutputHandler func(text string) // called for each text chunk, can be nil
174
        Debug         bool              // enable debug output
175
        ErrorPatterns []string          // patterns to detect in output (e.g., rate limit messages)
176
        cmdRunner     CommandRunner     // for testing, nil uses default
177
}
178

179
// Run executes claude CLI with the given prompt and parses streaming JSON output.
180
func (e *ClaudeExecutor) Run(ctx context.Context, prompt string) Result {
15✔
181
        cmd := e.Command
15✔
182
        if cmd == "" {
28✔
183
                cmd = "claude"
13✔
184
        }
13✔
185

186
        // build args from configured string or use defaults
187
        var args []string
15✔
188
        if e.Args != "" {
17✔
189
                args = splitArgs(e.Args)
2✔
190
        } else {
15✔
191
                args = []string{
13✔
192
                        "--dangerously-skip-permissions",
13✔
193
                        "--output-format", "stream-json",
13✔
194
                        "--verbose",
13✔
195
                }
13✔
196
        }
13✔
197
        args = append(args, "-p", prompt)
15✔
198

15✔
199
        runner := e.cmdRunner
15✔
200
        if runner == nil {
15✔
201
                runner = &execClaudeRunner{}
×
202
        }
×
203

204
        stdout, wait, err := runner.Run(ctx, cmd, args...)
15✔
205
        if err != nil {
16✔
206
                return Result{Error: err}
1✔
207
        }
1✔
208

209
        result := e.parseStream(ctx, stdout)
14✔
210

14✔
211
        if err := wait(); err != nil {
17✔
212
                // check if it was context cancellation
3✔
213
                if ctx.Err() != nil {
4✔
214
                        return Result{Output: result.Output, Signal: result.Signal, Error: ctx.Err()}
1✔
215
                }
1✔
216
                // non-zero exit might still have useful output
217
                if result.Output == "" {
3✔
218
                        return Result{Error: fmt.Errorf("claude exited with error: %w", err)}
1✔
219
                }
1✔
220
        }
221

222
        // check for error patterns in output
223
        if pattern := checkErrorPatterns(result.Output, e.ErrorPatterns); pattern != "" {
16✔
224
                return Result{
4✔
225
                        Output: result.Output,
4✔
226
                        Signal: result.Signal,
4✔
227
                        Error:  &PatternMatchError{Pattern: pattern, HelpCmd: "claude /usage"},
4✔
228
                }
4✔
229
        }
4✔
230

231
        return result
8✔
232
}
233

234
// parseStream reads and parses the JSON stream from claude CLI.
235
// checks ctx.Done() on each iteration so cancellation is not blocked by slow pipe reads.
236
func (e *ClaudeExecutor) parseStream(ctx context.Context, r io.Reader) Result {
33✔
237
        var output strings.Builder
33✔
238
        var signal string
33✔
239

33✔
240
        scanner := bufio.NewScanner(r)
33✔
241
        // increase buffer size for large JSON lines (large diffs with parallel agents)
33✔
242
        buf := make([]byte, 0, 64*1024)
33✔
243
        scanner.Buffer(buf, MaxScannerBuffer)
33✔
244

33✔
245
        for scanner.Scan() {
76✔
246
                select {
43✔
NEW
247
                case <-ctx.Done():
×
NEW
248
                        return Result{Output: output.String(), Signal: signal, Error: fmt.Errorf("stream read: %w", ctx.Err())}
×
249
                default:
43✔
250
                }
251
                line := scanner.Text()
43✔
252
                if line == "" {
46✔
253
                        continue
3✔
254
                }
255

256
                var event streamEvent
40✔
257
                if err := json.Unmarshal([]byte(line), &event); err != nil {
42✔
258
                        // print non-JSON lines as-is
2✔
259
                        if e.Debug {
3✔
260
                                fmt.Printf("[debug] non-JSON line: %s\n", line)
1✔
261
                        }
1✔
262
                        output.WriteString(line)
2✔
263
                        output.WriteString("\n")
2✔
264
                        if e.OutputHandler != nil {
2✔
265
                                e.OutputHandler(line + "\n")
×
266
                        }
×
267
                        continue
2✔
268
                }
269

270
                text := e.extractText(&event)
38✔
271
                if text != "" {
75✔
272
                        output.WriteString(text)
37✔
273
                        if e.OutputHandler != nil {
41✔
274
                                e.OutputHandler(text)
4✔
275
                        }
4✔
276

277
                        // check for signals in text
278
                        if sig := detectSignal(text); sig != "" {
44✔
279
                                signal = sig
7✔
280
                        }
7✔
281
                }
282
        }
283

284
        if err := scanner.Err(); err != nil {
33✔
285
                return Result{Output: output.String(), Signal: signal, Error: fmt.Errorf("stream read: %w", err)}
×
286
        }
×
287

288
        return Result{Output: output.String(), Signal: signal}
33✔
289
}
290

291
// extractText extracts text content from various event types.
292
func (e *ClaudeExecutor) extractText(event *streamEvent) string {
49✔
293
        switch event.Type {
49✔
294
        case "assistant":
4✔
295
                // assistant events contain message.content array with text blocks
4✔
296
                var texts []string
4✔
297
                for _, c := range event.Message.Content {
8✔
298
                        if c.Type == "text" && c.Text != "" {
8✔
299
                                texts = append(texts, c.Text)
4✔
300
                        }
4✔
301
                }
302
                return strings.Join(texts, "")
4✔
303
        case "content_block_delta":
37✔
304
                if event.Delta.Type == "text_delta" {
73✔
305
                        return event.Delta.Text
36✔
306
                }
36✔
307
        case "message_stop":
3✔
308
                // check final message content
3✔
309
                for _, c := range event.Message.Content {
5✔
310
                        if c.Type == "text" {
3✔
311
                                return c.Text
1✔
312
                        }
1✔
313
                }
314
        case "result":
3✔
315
                // result can be a string or object with "output" field
3✔
316
                if len(event.Result) == 0 {
3✔
317
                        return ""
×
318
                }
×
319
                // try as string first (session summary format)
320
                var resultStr string
3✔
321
                if err := json.Unmarshal(event.Result, &resultStr); err == nil {
4✔
322
                        return "" // skip session summary - content already streamed
1✔
323
                }
1✔
324
                // try as object with output field
325
                var resultObj struct {
2✔
326
                        Output string `json:"output"`
2✔
327
                }
2✔
328
                if err := json.Unmarshal(event.Result, &resultObj); err == nil {
4✔
329
                        return resultObj.Output
2✔
330
                }
2✔
331
        }
332
        return ""
5✔
333
}
334

335
// detectSignal checks text for completion status.
336
// looks for <<<RALPHEX:...>>> format status.
337
func detectSignal(text string) string {
90✔
338
        knownSignals := []string{
90✔
339
                status.Completed,
90✔
340
                status.Failed,
90✔
341
                status.ReviewDone,
90✔
342
                status.CodexDone,
90✔
343
                status.PlanReady,
90✔
344
        }
90✔
345
        for _, sig := range knownSignals {
495✔
346
                if strings.Contains(text, sig) {
429✔
347
                        return sig
24✔
348
                }
24✔
349
        }
350
        return ""
66✔
351
}
352

353
// checkErrorPatterns checks output for configured error patterns.
354
// Returns the first matching pattern or empty string if none match.
355
// Matching is case-insensitive substring search.
356
func checkErrorPatterns(output string, patterns []string) string {
58✔
357
        if len(patterns) == 0 {
92✔
358
                return ""
34✔
359
        }
34✔
360
        outputLower := strings.ToLower(output)
24✔
361
        for _, pattern := range patterns {
54✔
362
                trimmed := strings.TrimSpace(pattern)
30✔
363
                if trimmed == "" {
31✔
364
                        continue
1✔
365
                }
366
                if strings.Contains(outputLower, strings.ToLower(trimmed)) {
49✔
367
                        return trimmed
20✔
368
                }
20✔
369
        }
370
        return ""
4✔
371
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc