From 89bf87a5f23f27db27190a9bb3999c5631e7e38a Mon Sep 17 00:00:00 2001 From: Travis Thieman Date: Wed, 25 Mar 2026 16:13:38 -0400 Subject: [PATCH 1/2] fix(interp): propagate context cancellation through heredoc pipe goroutines Heredoc-writing goroutines in runner_redir.go previously ignored context cancellation. If the shell context was cancelled while heredoc content was being written to a pipe, the goroutine would continue writing until the pipe buffer filled or the reader closed. Fix: goroutines now check ctx.Err() before writing and between 32 KiB chunks, closing the pipe on cancellation to unblock the reader. Also adds an explicit RULES.md requirement: all goroutines spawned during execution must propagate context cancellation. Co-Authored-By: Claude Sonnet 4.6 --- docs/RULES.md | 3 +++ interp/runner_redir.go | 46 ++++++++++++++++++++++++++++++++++++++---- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/docs/RULES.md b/docs/RULES.md index 67d1b919..82b1184e 100644 --- a/docs/RULES.md +++ b/docs/RULES.md @@ -126,6 +126,9 @@ only the filesystem-accessing *functions* are forbidden. - Commands MUST NOT exhaust file descriptors or other system resources - Commands MUST handle interruption (context cancellation) gracefully +### Goroutine Context Propagation +- Goroutines spawned during command or interpreter execution MUST propagate and respect context cancellation. Before any blocking I/O in a goroutine, check `ctx.Err()`. For multi-chunk writes, check `ctx.Err()` between chunks. Close the pipe write end (`pw.Close()`) on cancellation to unblock the reader and propagate termination. + ### Integer Safety - Commands MUST check for integer overflow in all arithmetic operations - Commands MUST validate numeric conversions (string to int) and handle errors diff --git a/interp/runner_redir.go b/interp/runner_redir.go index 16bd8776..5b5ee8c4 100644 --- a/interp/runner_redir.go +++ b/interp/runner_redir.go @@ -69,7 +69,7 @@ func hdocLiteralPart(buf *strings.Builder, part syntax.WordPart) { } } -func (r *Runner) hdocReader(rd *syntax.Redirect) (*os.File, error) { +func (r *Runner) hdocReader(ctx context.Context, rd *syntax.Redirect) (*os.File, error) { pr, pw, err := os.Pipe() if err != nil { return nil, err @@ -94,7 +94,26 @@ func (r *Runner) hdocReader(rd *syntax.Redirect) (*os.File, error) { return nil, fmt.Errorf("heredoc: content exceeds maximum size (%d bytes)", MaxHeredocBytes) } go func() { - pw.WriteString(hdoc) + if ctx.Err() != nil { + pw.Close() + return + } + const chunkSize = 32 * 1024 + data := []byte(hdoc) + for len(data) > 0 { + if ctx.Err() != nil { + pw.Close() + return + } + n := chunkSize + if n > len(data) { + n = len(data) + } + if _, err := pw.Write(data[:n]); err != nil { + return + } + data = data[n:] + } pw.Close() }() return pr, nil @@ -144,7 +163,26 @@ func (r *Runner) hdocReader(rd *syntax.Redirect) (*os.File, error) { return nil, hdocErr } go func() { - pw.Write(buf.Bytes()) + if ctx.Err() != nil { + pw.Close() + return + } + const chunkSize = 32 * 1024 + data := buf.Bytes() + for len(data) > 0 { + if ctx.Err() != nil { + pw.Close() + return + } + n := chunkSize + if n > len(data) { + n = len(data) + } + if _, err := pw.Write(data[:n]); err != nil { + return + } + data = data[n:] + } pw.Close() }() return pr, nil @@ -152,7 +190,7 @@ func (r *Runner) hdocReader(rd *syntax.Redirect) (*os.File, error) { func (r *Runner) redir(ctx context.Context, rd *syntax.Redirect) (io.Closer, error) { if rd.Hdoc != nil { - pr, err := r.hdocReader(rd) + pr, err := r.hdocReader(ctx, rd) if err != nil { return nil, err } From 994a774ede1ffd665e88dfb47db128c895e3fcf4 Mon Sep 17 00:00:00 2001 From: Travis Thieman Date: Wed, 25 Mar 2026 16:28:44 -0400 Subject: [PATCH 2/2] fix(interp): ensure pw.Close() is always called in heredoc goroutines --- interp/runner_redir.go | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/interp/runner_redir.go b/interp/runner_redir.go index 5b5ee8c4..28cb0568 100644 --- a/interp/runner_redir.go +++ b/interp/runner_redir.go @@ -94,15 +94,11 @@ func (r *Runner) hdocReader(ctx context.Context, rd *syntax.Redirect) (*os.File, return nil, fmt.Errorf("heredoc: content exceeds maximum size (%d bytes)", MaxHeredocBytes) } go func() { - if ctx.Err() != nil { - pw.Close() - return - } + defer pw.Close() const chunkSize = 32 * 1024 data := []byte(hdoc) for len(data) > 0 { if ctx.Err() != nil { - pw.Close() return } n := chunkSize @@ -114,7 +110,6 @@ func (r *Runner) hdocReader(ctx context.Context, rd *syntax.Redirect) (*os.File, } data = data[n:] } - pw.Close() }() return pr, nil } @@ -163,15 +158,11 @@ func (r *Runner) hdocReader(ctx context.Context, rd *syntax.Redirect) (*os.File, return nil, hdocErr } go func() { - if ctx.Err() != nil { - pw.Close() - return - } + defer pw.Close() const chunkSize = 32 * 1024 data := buf.Bytes() for len(data) > 0 { if ctx.Err() != nil { - pw.Close() return } n := chunkSize @@ -183,7 +174,6 @@ func (r *Runner) hdocReader(ctx context.Context, rd *syntax.Redirect) (*os.File, } data = data[n:] } - pw.Close() }() return pr, nil }