Skip to content
Merged
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
42 changes: 31 additions & 11 deletions pkg/executor/linereader.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,24 +12,44 @@ import (
// uses bufio.Reader internally, so there is no line length limit.
// strips trailing \n and \r\n from lines before passing to handler (matching bufio.ScanLines behavior).
// returns nil on EOF, or a wrapped error on context cancellation or read failure.
//
// each blocking ReadString call runs in a goroutine so that context cancellation
// is detected even while the pipe read is in progress (important on Windows where
// child processes may hold the pipe open after the parent exits, causing ReadString
// to block indefinitely until the process is explicitly killed).
func readLines(ctx context.Context, r io.Reader, handler func(string)) error {
type readResult struct {
line string
err error
}

reader := bufio.NewReader(r)
ch := make(chan readResult, 1) // buffered: lets abandoned goroutine exit after kill

doRead := func() {
line, err := reader.ReadString('\n')
ch <- readResult{line, err}
}

go doRead()

Comment on lines 26 to +35
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

readLines() now always starts a ReadString goroutine before checking ctx. If ctx is already canceled, there’s a race where the read goroutine can fill ch and the select may take the res := <-ch branch, potentially draining input and returning nil/EOF instead of context.Canceled (can make cancellation propagation and tests flaky). Consider checking ctx.Err() up front (before starting the first goroutine) and/or prioritizing cancellation inside the loop (e.g., if ctx.Err()!=nil when receiving res, return the context error without processing/spawning another read).

Copilot uses AI. Check for mistakes.
for {
select {
case <-ctx.Done():
// goroutine is still blocked on ReadString; it will unblock when the process
// is killed (killProcess fires on context cancel) and drain into the buffered ch.
return fmt.Errorf("read lines: %w", ctx.Err())
default:
}
line, err := reader.ReadString('\n')
if line != "" {
line = trimLineEnding(line)
handler(line)
}
if err != nil {
if errors.Is(err, io.EOF) {
return nil
case res := <-ch:
if res.line != "" {
handler(trimLineEnding(res.line))
}
if res.err != nil {
if errors.Is(res.err, io.EOF) {
return nil
}
return fmt.Errorf("read lines: %w", res.err)
}
return fmt.Errorf("read lines: %w", err)
go doRead()
}
Comment on lines +49 to 53
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

This implementation spawns a new goroutine for every line (go doRead() on each successful read). For long-running/high-volume streams this adds avoidable goroutine creation/teardown overhead. A lower-overhead approach is to run a single long-lived reader goroutine that loops on ReadString and sends readResults on a channel, while the main goroutine selects on ctx.Done() vs results.

Copilot uses AI. Check for mistakes.
}
}
Expand Down