From 36d75c241d163227ec86567f171903342b5d7287 Mon Sep 17 00:00:00 2001 From: Vasilis Vamvakas Date: Mon, 6 Jul 2026 15:40:45 +0100 Subject: [PATCH 1/3] pass optional context to pipe --- README.md | 2 + script.go | 24 ++++++++++-- script_test.go | 102 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 038e31e..c80efc6 100644 --- a/README.md +++ b/README.md @@ -328,6 +328,8 @@ These are methods on a pipe that change its configuration: | [`WithReader`](https://pkg.go.dev/github.com/bitfield/script#Pipe.WithReader) | pipe source | | [`WithStderr`](https://pkg.go.dev/github.com/bitfield/script#Pipe.WithStderr) | standard error output stream for command | | [`WithStdout`](https://pkg.go.dev/github.com/bitfield/script#Pipe.WithStdout) | standard output stream for pipe | +| [`WithContext`](https://pkg.go.dev/github.com/bitfield/script#Pipe.WithContext) | context used in +exec and http requests | ## Filters diff --git a/script.go b/script.go index 2195da4..fd1c045 100644 --- a/script.go +++ b/script.go @@ -3,6 +3,7 @@ package script import ( "bufio" "container/ring" + "context" "crypto/sha256" "encoding/base64" "encoding/hex" @@ -38,6 +39,7 @@ type Pipe struct { err error stderr io.Writer env []string + ctx context.Context } // Args creates a pipe containing the program's command-line arguments from @@ -174,6 +176,7 @@ func NewPipe() *Pipe { stdout: os.Stdout, httpClient: http.DefaultClient, env: nil, + ctx: context.Background(), } } @@ -432,7 +435,7 @@ func (p *Pipe) Exec(cmdLine string) *Pipe { if err != nil { return err } - cmd := exec.Command(args[0], args[1:]...) + cmd := exec.CommandContext(p.ctx, args[0], args[1:]...) cmd.Stdin = r cmd.Stdout = w cmd.Stderr = w @@ -470,6 +473,9 @@ func (p *Pipe) ExecForEach(cmdLine string) *Pipe { return p.Filter(func(r io.Reader, w io.Writer) error { scanner := newScanner(r) for scanner.Scan() { + if p.ctx.Err() != nil { + return p.ctx.Err() + } cmdLine := new(strings.Builder) err := tpl.Execute(cmdLine, scanner.Text()) if err != nil { @@ -479,7 +485,7 @@ func (p *Pipe) ExecForEach(cmdLine string) *Pipe { if err != nil { return err } - cmd := exec.Command(args[0], args[1:]...) + cmd := exec.CommandContext(p.ctx, args[0], args[1:]...) cmd.Stdout = w cmd.Stderr = w pipeStderr := p.stdErr() @@ -652,7 +658,7 @@ func (p *Pipe) Freq() *Pipe { // the request body, and produces the server's response. See [Pipe.Do] for how // the HTTP response status is interpreted. func (p *Pipe) Get(url string) *Pipe { - req, err := http.NewRequest(http.MethodGet, url, p.Reader) + req, err := http.NewRequestWithContext(p.ctx, http.MethodGet, url, p.Reader) if err != nil { return p.WithError(err) } @@ -807,7 +813,7 @@ func (p *Pipe) MatchRegexp(re *regexp.Regexp) *Pipe { // the request body, and produces the server's response. See [Pipe.Do] for how // the HTTP response status is interpreted. func (p *Pipe) Post(url string) *Pipe { - req, err := http.NewRequest(http.MethodPost, url, p.Reader) + req, err := http.NewRequestWithContext(p.ctx, http.MethodPost, url, p.Reader) if err != nil { return p.WithError(err) } @@ -1014,6 +1020,16 @@ func (p *Pipe) WithStdout(w io.Writer) *Pipe { return p } +// WithContext sets the context for subsequent [Pipe.Exec], [Pipe.ExecForEach], [Pipe.Get] and [Pipe.Post] commands . +func (p *Pipe) WithContext(ctx context.Context) *Pipe { + p.ctx = ctx + return p +} + +func WithContext(ctx context.Context) *Pipe { + return NewPipe().WithContext(ctx) +} + // WriteFile writes the pipe's contents to the file path, truncating it if it // exists, and returns the number of bytes successfully written, or an error. func (p *Pipe) WriteFile(path string) (int64, error) { diff --git a/script_test.go b/script_test.go index 66e4df5..383a12b 100644 --- a/script_test.go +++ b/script_test.go @@ -3,6 +3,7 @@ package script_test import ( "bufio" "bytes" + "context" "crypto/sha256" "crypto/sha512" "errors" @@ -18,6 +19,7 @@ import ( "strings" "testing" "testing/iotest" + "time" "github.com/bitfield/script" "github.com/google/go-cmp/cmp" @@ -2175,6 +2177,106 @@ func TestHashSums_OutputsEmptyStringForFileThatCannotBeHashed(t *testing.T) { } } +func TestWithContext_CallTimeoutMidstream(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + err := script.WithContext(ctx).Exec("sleep 10").Wait() + if err == nil { + t.Fatal("expected error due to context cancellation, got nil") + } +} + +func TestWithContext_CallCancelBeforeExec(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + cancel() + err := script.WithContext(ctx).Exec("echo Hello, World").Wait() + if err == nil { + t.Fatal("expected error due to context cancellation, got nil") + } +} + +func TestWithContext_GetWithContextTimeout(t *testing.T) { + t.Parallel() + tcs := []struct { + name string + timeout time.Duration + expErr bool + }{ + { + name: "timeout less than server response time", + timeout: 500 * time.Millisecond, + expErr: true, + }, + { + name: "timeout greater than server response time", + timeout: 2 * time.Second, + expErr: false, + }, + } + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(1 * time.Second) + })) + defer ts.Close() + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), tc.timeout) + defer cancel() + _, err := script.WithContext(ctx).Echo("request data").Get(ts.URL).String() + if tc.expErr { + if err == nil { + t.Fatalf("expected error due to context timeout, got nil") + } + } else { + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + } + }) + } +} + +func TestWithContext_PostWithContextTimeout(t *testing.T) { + t.Parallel() + tcs := []struct { + name string + timeout time.Duration + expErr bool + }{ + { + name: "timeout less than server response time", + timeout: 500 * time.Millisecond, + expErr: true, + }, + { + name: "timeout greater than server response time", + timeout: 2 * time.Second, + expErr: false, + }, + } + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(1 * time.Second) + })) + defer ts.Close() + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), tc.timeout) + defer cancel() + _, err := script.WithContext(ctx).Echo("request data").Post(ts.URL).String() + if tc.expErr { + if err == nil { + t.Fatalf("expected error due to context timeout, got nil") + } + } else { + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + } + }) + } +} + func ExampleArgs() { script.Args().Stdout() // prints command-line arguments From e5fd998d0f3b6333bd9688ad772ddb650ba5d32e Mon Sep 17 00:00:00 2001 From: Vasilis Vamvakas Date: Thu, 30 Jul 2026 14:40:43 +0100 Subject: [PATCH 2/3] addressing comments --- README.md | 2 +- script.go | 2 +- script_test.go | 101 ++++++++++--------------------------------------- 3 files changed, 22 insertions(+), 83 deletions(-) diff --git a/README.md b/README.md index c80efc6..e8a970d 100644 --- a/README.md +++ b/README.md @@ -322,13 +322,13 @@ These are methods on a pipe that change its configuration: | Source | Modifies | | -------- | ------------- | +| [`WithContext`](https://pkg.go.dev/github.com/bitfield/script#Pipe.WithContext) | context used in | [`WithEnv`](https://pkg.go.dev/github.com/bitfield/script#Pipe.WithEnv) | environment for commands | | [`WithError`](https://pkg.go.dev/github.com/bitfield/script#Pipe.WithError) | pipe error status | | [`WithHTTPClient`](https://pkg.go.dev/github.com/bitfield/script#Pipe.WithHTTPClient) | client for HTTP requests | | [`WithReader`](https://pkg.go.dev/github.com/bitfield/script#Pipe.WithReader) | pipe source | | [`WithStderr`](https://pkg.go.dev/github.com/bitfield/script#Pipe.WithStderr) | standard error output stream for command | | [`WithStdout`](https://pkg.go.dev/github.com/bitfield/script#Pipe.WithStdout) | standard output stream for pipe | -| [`WithContext`](https://pkg.go.dev/github.com/bitfield/script#Pipe.WithContext) | context used in exec and http requests | ## Filters diff --git a/script.go b/script.go index fd1c045..1b4ee8a 100644 --- a/script.go +++ b/script.go @@ -1020,7 +1020,7 @@ func (p *Pipe) WithStdout(w io.Writer) *Pipe { return p } -// WithContext sets the context for subsequent [Pipe.Exec], [Pipe.ExecForEach], [Pipe.Get] and [Pipe.Post] commands . +// WithContext sets the context for subsequent [Pipe.Exec], [Pipe.ExecForEach], [Pipe.Get] and [Pipe.Post] commands. func (p *Pipe) WithContext(ctx context.Context) *Pipe { p.ctx = ctx return p diff --git a/script_test.go b/script_test.go index 383a12b..6a10636 100644 --- a/script_test.go +++ b/script_test.go @@ -2177,103 +2177,42 @@ func TestHashSums_OutputsEmptyStringForFileThatCannotBeHashed(t *testing.T) { } } -func TestWithContext_CallTimeoutMidstream(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) - defer cancel() - err := script.WithContext(ctx).Exec("sleep 10").Wait() - if err == nil { - t.Fatal("expected error due to context cancellation, got nil") - } -} - func TestWithContext_CallCancelBeforeExec(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) cancel() - err := script.WithContext(ctx).Exec("echo Hello, World").Wait() + err := script.NewPipe().WithContext(ctx).Exec("echo Hello, World").Wait() if err == nil { t.Fatal("expected error due to context cancellation, got nil") } } -func TestWithContext_GetWithContextTimeout(t *testing.T) { +func TestWithContext_GetCanceledContext(t *testing.T) { t.Parallel() - tcs := []struct { - name string - timeout time.Duration - expErr bool - }{ - { - name: "timeout less than server response time", - timeout: 500 * time.Millisecond, - expErr: true, - }, - { - name: "timeout greater than server response time", - timeout: 2 * time.Second, - expErr: false, - }, - } - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - time.Sleep(1 * time.Second) - })) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) defer ts.Close() - for _, tc := range tcs { - t.Run(tc.name, func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), tc.timeout) - defer cancel() - _, err := script.WithContext(ctx).Echo("request data").Get(ts.URL).String() - if tc.expErr { - if err == nil { - t.Fatalf("expected error due to context timeout, got nil") - } - } else { - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - } - }) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := script.WithContext(ctx).Echo("request data").Get(ts.URL).String() + if err == nil { + t.Fatal("expected error from canceled context") } } -func TestWithContext_PostWithContextTimeout(t *testing.T) { +func TestWithContext_PostCanceledContext(t *testing.T) { t.Parallel() - tcs := []struct { - name string - timeout time.Duration - expErr bool - }{ - { - name: "timeout less than server response time", - timeout: 500 * time.Millisecond, - expErr: true, - }, - { - name: "timeout greater than server response time", - timeout: 2 * time.Second, - expErr: false, - }, - } - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - time.Sleep(1 * time.Second) - })) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) defer ts.Close() - for _, tc := range tcs { - t.Run(tc.name, func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), tc.timeout) - defer cancel() - _, err := script.WithContext(ctx).Echo("request data").Post(ts.URL).String() - if tc.expErr { - if err == nil { - t.Fatalf("expected error due to context timeout, got nil") - } - } else { - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - } - }) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := script.WithContext(ctx).Echo("request data").Post(ts.URL).String() + if err == nil { + t.Fatal("expected error from canceled context") } } From e5e8d0bf3d48ad7fa0b768b7b19681c03b0f35b1 Mon Sep 17 00:00:00 2001 From: John Arundel Date: Thu, 30 Jul 2026 17:35:29 +0100 Subject: [PATCH 3/3] tweaks --- README.md | 3 +-- script.go | 59 +++++++++++++++++++++++++++++++++++++++----------- script_test.go | 56 ++++++++++++++++++++++++++++++++++------------- 3 files changed, 88 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index e8a970d..7e6e332 100644 --- a/README.md +++ b/README.md @@ -322,14 +322,13 @@ These are methods on a pipe that change its configuration: | Source | Modifies | | -------- | ------------- | -| [`WithContext`](https://pkg.go.dev/github.com/bitfield/script#Pipe.WithContext) | context used in +| [`WithContext`](https://pkg.go.dev/github.com/bitfield/script#Pipe.WithContext) | context used in commands or HTTP requests | | [`WithEnv`](https://pkg.go.dev/github.com/bitfield/script#Pipe.WithEnv) | environment for commands | | [`WithError`](https://pkg.go.dev/github.com/bitfield/script#Pipe.WithError) | pipe error status | | [`WithHTTPClient`](https://pkg.go.dev/github.com/bitfield/script#Pipe.WithHTTPClient) | client for HTTP requests | | [`WithReader`](https://pkg.go.dev/github.com/bitfield/script#Pipe.WithReader) | pipe source | | [`WithStderr`](https://pkg.go.dev/github.com/bitfield/script#Pipe.WithStderr) | standard error output stream for command | | [`WithStdout`](https://pkg.go.dev/github.com/bitfield/script#Pipe.WithStdout) | standard output stream for pipe | -exec and http requests | ## Filters diff --git a/script.go b/script.go index 1b4ee8a..457505d 100644 --- a/script.go +++ b/script.go @@ -201,6 +201,11 @@ func Stdin() *Pipe { return NewPipe().WithReader(os.Stdin) } +// WithContext creates a pipe with the context ctx. +func WithContext(ctx context.Context) *Pipe { + return NewPipe().WithContext(ctx) +} + // AppendFile appends the contents of the pipe to the file path, creating it if // necessary, and returns the number of bytes successfully written, or an // error. @@ -329,6 +334,12 @@ func (p *Pipe) Dirname() *Pipe { // set by [Pipe.WithHTTPClient], or [http.DefaultClient] otherwise. The // response body is streamed concurrently to the pipe's output. If the response // status is anything other than HTTP 200-299, the pipe's error status is set. +// +// # Context +// +// The request does not inherit the pipe's context (if any was set by +// [Pipe.WithContext]). If you want the request to be cancellable, you will need to set +// your own context on it. func (p *Pipe) Do(req *http.Request) *Pipe { return p.Filter(func(r io.Reader, w io.Writer) error { resp, err := p.httpClient.Do(req) @@ -416,6 +427,11 @@ func (p *Pipe) Error() error { // The command inherits the current process's environment, optionally modified // by [Pipe.WithEnv]. // +// # Context +// +// The command inherits the pipe's context (if any was set by [Pipe.WithContext]), and +// will be cancelled if the context is cancelled or times out. +// // # Error handling // // If the command had a non-zero exit status, the pipe's error status will also @@ -465,6 +481,16 @@ func (p *Pipe) Exec(cmdLine string) *Pipe { // syntax. For example: // // ListFiles("*").ExecForEach("touch {{.}}").Wait() +// +// # Environment +// +// The command inherits the current process's environment, optionally modified +// by [Pipe.WithEnv]. +// +// # Context +// +// The command inherits the pipe's context (if any was set by [Pipe.WithContext]), and +// will be cancelled if the context is cancelled or times out. func (p *Pipe) ExecForEach(cmdLine string) *Pipe { tpl, err := template.New("").Parse(cmdLine) if err != nil { @@ -536,9 +562,10 @@ func (p *Pipe) ExitStatus() int { // [io.Writer] to write its output to, and returns an error, which will be set // on the pipe. // -// filter runs concurrently, so its goroutine will not exit until the pipe has -// been fully read. Use [Pipe.Wait] to wait for all concurrent filters to -// complete. +// filter runs concurrently, so its goroutine will not exit until the pipe has been +// fully read. Use [Pipe.Wait] to wait for all concurrent filters to complete. These +// goroutines are not automatically cancelled by the pipe's context, if one was set by +// [Pipe.WithContext]. func (p *Pipe) Filter(filter func(io.Reader, io.Writer) error) *Pipe { if p.Error() != nil { return p @@ -657,6 +684,11 @@ func (p *Pipe) Freq() *Pipe { // Get makes an HTTP GET request to url, sending the contents of the pipe as // the request body, and produces the server's response. See [Pipe.Do] for how // the HTTP response status is interpreted. +// +// # Context +// +// The request inherits the pipe's context (if any was set by [Pipe.WithContext]), and +// will be cancelled if the context is cancelled or times out. func (p *Pipe) Get(url string) *Pipe { req, err := http.NewRequestWithContext(p.ctx, http.MethodGet, url, p.Reader) if err != nil { @@ -812,6 +844,11 @@ func (p *Pipe) MatchRegexp(re *regexp.Regexp) *Pipe { // Post makes an HTTP POST request to url, using the contents of the pipe as // the request body, and produces the server's response. See [Pipe.Do] for how // the HTTP response status is interpreted. +// +// # Context +// +// The request inherits the pipe's context (if any was set by [Pipe.WithContext]), and +// will be cancelled if the context is cancelled or times out. func (p *Pipe) Post(url string) *Pipe { req, err := http.NewRequestWithContext(p.ctx, http.MethodPost, url, p.Reader) if err != nil { @@ -969,6 +1006,12 @@ func (p *Pipe) Wait() error { return p.Error() } +// WithContext sets the context for subsequent [Pipe.Exec], [Pipe.ExecForEach], [Pipe.Get] and [Pipe.Post] commands. +func (p *Pipe) WithContext(ctx context.Context) *Pipe { + p.ctx = ctx + return p +} + // WithEnv sets the environment for subsequent [Pipe.Exec] and [Pipe.ExecForEach] // commands to the string slice env, using the same format as [os/exec.Cmd.Env]. // An empty slice unsets all existing environment variables. @@ -1020,16 +1063,6 @@ func (p *Pipe) WithStdout(w io.Writer) *Pipe { return p } -// WithContext sets the context for subsequent [Pipe.Exec], [Pipe.ExecForEach], [Pipe.Get] and [Pipe.Post] commands. -func (p *Pipe) WithContext(ctx context.Context) *Pipe { - p.ctx = ctx - return p -} - -func WithContext(ctx context.Context) *Pipe { - return NewPipe().WithContext(ctx) -} - // WriteFile writes the pipe's contents to the file path, truncating it if it // exists, and returns the number of bytes successfully written, or an error. func (p *Pipe) WriteFile(path string) (int64, error) { diff --git a/script_test.go b/script_test.go index 6a10636..c4f9a77 100644 --- a/script_test.go +++ b/script_test.go @@ -2167,6 +2167,7 @@ func TestHash_ReturnsErrorGivenReadErrorOnPipe(t *testing.T) { } func TestHashSums_OutputsEmptyStringForFileThatCannotBeHashed(t *testing.T) { + t.Parallel() got, err := script.Echo("file_does_not_exist.txt").HashSums(sha256.New()).String() if err != nil { t.Fatal(err) @@ -2177,42 +2178,52 @@ func TestHashSums_OutputsEmptyStringForFileThatCannotBeHashed(t *testing.T) { } } -func TestWithContext_CallCancelBeforeExec(t *testing.T) { +func TestWithContext_SkipsExecWithCancelledContext(t *testing.T) { + t.Parallel() ctx, cancel := context.WithTimeout(context.Background(), time.Second) cancel() - err := script.NewPipe().WithContext(ctx).Exec("echo Hello, World").Wait() + output, err := script.WithContext(ctx).Exec("echo Hello, World").String() if err == nil { - t.Fatal("expected error due to context cancellation, got nil") + t.Error("expected error due to context cancellation, got nil") + } + if strings.Contains(output, "Hello") { + t.Fatal("command ran even though context cancelled beforehand") } } -func TestWithContext_GetCanceledContext(t *testing.T) { +func TestWithContext_SkipsGETRequestWithCancelledContext(t *testing.T) { t.Parallel() - - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + handler_called := false + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler_called = true + })) defer ts.Close() - ctx, cancel := context.WithCancel(context.Background()) cancel() - _, err := script.WithContext(ctx).Echo("request data").Get(ts.URL).String() if err == nil { - t.Fatal("expected error from canceled context") + t.Fatal("expected error from cancelled context") + } + if handler_called { + t.Fatal("request made even though context cancelled beforehand") } } -func TestWithContext_PostCanceledContext(t *testing.T) { +func TestWithContext_SkipsPOSTRequestWithCancelledContext(t *testing.T) { t.Parallel() - - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + handler_called := false + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler_called = true + })) defer ts.Close() - ctx, cancel := context.WithCancel(context.Background()) cancel() - _, err := script.WithContext(ctx).Echo("request data").Post(ts.URL).String() if err == nil { - t.Fatal("expected error from canceled context") + t.Fatal("expected error from cancelled context") + } + if handler_called { + t.Fatal("request made even though context cancelled beforehand") } } @@ -2663,6 +2674,21 @@ func ExamplePipe_Tee_writers() { // hello } +func ExamplePipe_WithContext() { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(20 * time.Millisecond) + })) + defer ts.Close() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + _, err := script.NewPipe().WithContext(ctx).Get(ts.URL).String() + if err != nil { + fmt.Println("cancelled") + } + // Output: + // cancelled +} + func ExamplePipe_WithStderr() { buf := new(bytes.Buffer) script.NewPipe().WithStderr(buf).Exec("go").Wait()