From edc34062e90f3dbd44fc41ecb4d22802aaa3c687 Mon Sep 17 00:00:00 2001 From: Dan Jones Date: Fri, 14 Nov 2025 14:04:51 -0600 Subject: [PATCH 1/8] =?UTF-8?q?=E2=9C=A8=20Refactor:=20Convert=20WaitErr?= =?UTF-8?q?=20to=20an=20interface=20and=20add=20New=20constructor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 ++-- example_test.go | 7 +++---- waiterr.go | 53 ++++++++++++++++++++++++++++++++----------------- waiterr_test.go | 25 +++++++---------------- 4 files changed, 47 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index b503e38..e7923a0 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ import ( ) func main() { - we := new(waiterr.WaitErr) + we := waiterr.New() we.Go(func() error { time.Sleep(100 * time.Millisecond) @@ -60,7 +60,7 @@ func main() { } // You can also get the first error immediately - we2 := new(waiterr.WaitErr) + we2 := waiterr.New() we2.Go(func() error { time.Sleep(100 * time.Millisecond) return errors.New("first error from we2") diff --git a/example_test.go b/example_test.go index 3246ddd..2f1c9cd 100644 --- a/example_test.go +++ b/example_test.go @@ -9,8 +9,7 @@ import ( ) func Example() { - we := new(waiterr.WaitErr) - + we := waiterr.New() we.Go(func() error { time.Sleep(100 * time.Millisecond) fmt.Println("Goroutine 1 finished") @@ -43,7 +42,7 @@ func Example() { } func ExampleWaitErr_WaitForError() { - we := new(waiterr.WaitErr) + we := waiterr.New() we.Go(func() error { time.Sleep(100 * time.Millisecond) return errors.New("first error from we") @@ -63,7 +62,7 @@ func ExampleWaitErr_WaitForError() { } func ExampleWaitErr_Unwrap() { - we := new(waiterr.WaitErr) + we := waiterr.New() we.Go(func() error { time.Sleep(100 * time.Millisecond) return errors.New("first error from we") diff --git a/waiterr.go b/waiterr.go index 0ba8821..94d6cd6 100644 --- a/waiterr.go +++ b/waiterr.go @@ -5,23 +5,43 @@ import ( "sync" ) +func New() WaitErr { + var we waitErr + we.errCh = make(chan error, 1) + + return &we +} + +// WaitErr provides a way to run multiple goroutines and wait for their completion, +// collecting any errors they return. +type WaitErr interface { + // Go runs f in its own goroutine. When f returns, its error is stored, and returned + // with [WaitErr.Wait]. + Go(f func() error) + // WaitForError waits for the first error to be returned by one of our go routines, and immediately returns + // with that error. If all functions return successfully, a nil is returned. + WaitForError() error + // Wait for all current goroutines to finish. Return an error that combines all errors returned + // in the group so far (if any). + Wait() error + // Unwrap returns all non-nil errors returned by our functions. + // If no errors were returned, or all errors are nil, it returns nil. + Unwrap() []error +} + // WaitErr wraps a [sync.WaitGroup] with error handling. -type WaitErr struct { - wg sync.WaitGroup - errs []error - mut sync.RWMutex - firstErr error - firstErrOnce sync.Once - errCh chan error // Buffered channel of size 1 - initErrChOnce sync.Once +type waitErr struct { + wg sync.WaitGroup + errs []error + mut sync.RWMutex + firstErr error + firstErrOnce sync.Once + errCh chan error // Buffered channel of size 1 } // Go runs f in its own goroutine. When f returns, its error is stored, and returned // with [WaitErr.Wait]. -func (we *WaitErr) Go(f func() error) { - we.initErrChOnce.Do(func() { - we.errCh = make(chan error, 1) - }) +func (we *waitErr) Go(f func() error) { wrap := func() { err := f() @@ -48,10 +68,7 @@ func (we *WaitErr) Go(f func() error) { // WaitForError waits for the first error to be returned by one of our go routines, and immediately returns // with that error. If all functions return successfully, a nil is returned. It will panic if called before Go. -func (we *WaitErr) WaitForError() error { - if we.errCh == nil { - panic("WaitForError called before Go") - } +func (we *waitErr) WaitForError() error { // Check if an error has already been set we.mut.RLock() if we.firstErr != nil { @@ -82,7 +99,7 @@ func (we *WaitErr) WaitForError() error { // Wait for all current goroutines to finish. Return an error that combines all errors returned // in the group so far (if any). -func (we *WaitErr) Wait() error { +func (we *waitErr) Wait() error { we.wg.Wait() we.mut.RLock() defer we.mut.RUnlock() @@ -91,7 +108,7 @@ func (we *WaitErr) Wait() error { // Unwrap returns all non-nil errors returned by our functions. // If no errors were returned, or all errors are nil, it returns nil. -func (we *WaitErr) Unwrap() []error { +func (we *waitErr) Unwrap() []error { errs := make([]error, 0, len(we.errs)) for _, e := range we.errs { if e != nil { diff --git a/waiterr_test.go b/waiterr_test.go index f2ba7b2..c173675 100644 --- a/waiterr_test.go +++ b/waiterr_test.go @@ -11,7 +11,7 @@ import ( ) func TestGo(t *testing.T) { - we := new(waiterr.WaitErr) + we := waiterr.New() err := errors.New("uh-oh") var run bool we.Go(func() error { @@ -24,7 +24,7 @@ func TestGo(t *testing.T) { } func TestWait(t *testing.T) { - we := new(waiterr.WaitErr) + we := waiterr.New() er1 := errors.New("uh-oh") er2 := errors.New("oops") we.Go(func() error { return er1 }) @@ -45,7 +45,7 @@ func TestWait(t *testing.T) { func TestWaitForError(tt *testing.T) { tt.Run("first error", func(t *testing.T) { - we := new(waiterr.WaitErr) + we := waiterr.New() er1 := errors.New("uh-oh") er2 := errors.New("oops") we.Go(func() error { return nil }) @@ -58,7 +58,7 @@ func TestWaitForError(tt *testing.T) { }) tt.Run("no error", func(t *testing.T) { - we := new(waiterr.WaitErr) + we := waiterr.New() we.Go(func() error { return nil }) we.Go(func() error { return nil }) we.Go(func() error { return nil }) @@ -67,19 +67,8 @@ func TestWaitForError(tt *testing.T) { be.Err(t, err, nil) }) - tt.Run("panic", func(t *testing.T) { - defer func() { - if r := recover(); r == nil { - t.Errorf("The code did not panic") - } - }() - - we := new(waiterr.WaitErr) - _ = we.WaitForError() - }) - tt.Run("first error set", func(tt2 *testing.T) { - we := new(waiterr.WaitErr) + we := waiterr.New() expectedErr := errors.New("pre-set error") synctest.Test(tt2, func(t *testing.T) { @@ -100,7 +89,7 @@ func TestWaitForError(tt *testing.T) { func TestUnwrap(tt *testing.T) { tt.Run("two errors", func(t *testing.T) { - we := new(waiterr.WaitErr) + we := waiterr.New() er1 := errors.New("error one") er2 := errors.New("error two") @@ -118,7 +107,7 @@ func TestUnwrap(tt *testing.T) { }) tt.Run("no errors", func(t *testing.T) { - weNoErr := new(waiterr.WaitErr) + weNoErr := waiterr.New() weNoErr.Go(func() error { return nil }) weNoErr.Go(func() error { return nil }) _ = weNoErr.Wait() From 09feb51213b3d27faf03ff519483f4b6058b584c Mon Sep 17 00:00:00 2001 From: Dan Jones Date: Fri, 14 Nov 2025 14:53:36 -0600 Subject: [PATCH 2/8] =?UTF-8?q?=E2=9C=A8=20Add=20WithContext=20function=20?= =?UTF-8?q?for=20cancellation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- waiterr.go | 17 ++++++++++++++++- waiterr_test.go | 27 +++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/waiterr.go b/waiterr.go index 94d6cd6..66a64b9 100644 --- a/waiterr.go +++ b/waiterr.go @@ -1,6 +1,7 @@ package waiterr import ( + "context" "errors" "sync" ) @@ -8,10 +9,20 @@ import ( func New() WaitErr { var we waitErr we.errCh = make(chan error, 1) + we.cancel = func(error) {} return &we } +func WithContext(ctx context.Context) (WaitErr, context.Context) { + var we waitErr + we.errCh = make(chan error, 1) + cCtx, canc := context.WithCancelCause(ctx) + we.cancel = canc + + return &we, cCtx +} + // WaitErr provides a way to run multiple goroutines and wait for their completion, // collecting any errors they return. type WaitErr interface { @@ -37,6 +48,7 @@ type waitErr struct { firstErr error firstErrOnce sync.Once errCh chan error // Buffered channel of size 1 + cancel context.CancelCauseFunc } // Go runs f in its own goroutine. When f returns, its error is stored, and returned @@ -49,6 +61,7 @@ func (we *waitErr) Go(f func() error) { we.firstErrOnce.Do(func() { we.mut.Lock() // Acquire lock before writing to firstErr we.firstErr = err + we.cancel(err) we.mut.Unlock() // Release lock after writing // Non-blocking send to errCh @@ -103,7 +116,9 @@ func (we *waitErr) Wait() error { we.wg.Wait() we.mut.RLock() defer we.mut.RUnlock() - return errors.Join(we.errs...) + ret := errors.Join(we.errs...) + we.cancel(ret) + return ret } // Unwrap returns all non-nil errors returned by our functions. diff --git a/waiterr_test.go b/waiterr_test.go index c173675..fb4818d 100644 --- a/waiterr_test.go +++ b/waiterr_test.go @@ -1,6 +1,7 @@ package waiterr_test import ( + "context" "errors" "testing" "testing/synctest" @@ -114,3 +115,29 @@ func TestUnwrap(tt *testing.T) { be.Equal(t, weNoErr.Unwrap(), nil) }) } + +func TestWithContext(tt *testing.T) { + tt.Run("with error", func(tt2 *testing.T) { + er1 := errors.New("uh-oh") + er2 := errors.New("oops") + synctest.Test(tt2, func(t *testing.T) { + we, ctx := waiterr.WithContext(t.Context()) + we.Go(func() error { return er1 }) + synctest.Wait() // Ensure it finishes first + we.Go(func() error { return er2 }) + + er := context.Cause(ctx) + be.Err(t, er, er1) + be.True(t, !errors.Is(er, er2)) + }) + }) + + tt.Run("no error", func(t *testing.T) { + we, ctx := waiterr.WithContext(t.Context()) + we.Go(func() error { return nil }) + we.Go(func() error { return nil }) + er := we.Wait() + be.Err(t, er, nil) + be.Err(t, context.Cause(ctx), context.Canceled) + }) +} From 09ebfaefbe161582e9a896af5568c59325762096 Mon Sep 17 00:00:00 2001 From: Dan Jones Date: Fri, 14 Nov 2025 15:07:21 -0600 Subject: [PATCH 3/8] =?UTF-8?q?=F0=9F=93=9D=20Add=20ExampleWithContext=20t?= =?UTF-8?q?o=20example=5Ftest.go?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- example_test.go | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/example_test.go b/example_test.go index 2f1c9cd..5e30d21 100644 --- a/example_test.go +++ b/example_test.go @@ -1,6 +1,7 @@ package waiterr_test import ( + "context" "errors" "fmt" "time" @@ -87,3 +88,50 @@ func ExampleWaitErr_Unwrap() { // second error from we // first error from we } + +func ExampleWithContext() { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + we, ctx := waiterr.WithContext(ctx) + + we.Go(func() error { + select { + case <-time.After(100 * time.Millisecond): + fmt.Println("Goroutine 1 finished") + return nil + case <-ctx.Done(): + return ctx.Err() + } + }) + + we.Go(func() error { + select { + case <-time.After(50 * time.Millisecond): + fmt.Println("Goroutine 2 finished with an error") + return errors.New("something went wrong in goroutine 2") + case <-ctx.Done(): + return ctx.Err() + } + }) + + we.Go(func() error { + select { + case <-time.After(150 * time.Millisecond): + fmt.Println("Goroutine 3 finished") + return nil + case <-ctx.Done(): + return ctx.Err() + } + }) + + if err := we.Wait(); err != nil { + fmt.Printf("All goroutines finished. Combined error: %s\n", err) + } + + // Output: + // Goroutine 2 finished with an error + // All goroutines finished. Combined error: something went wrong in goroutine 2 + // context canceled + // context canceled +} From 09a24ce72045e2b5e14a36ef2bcfb65848a9499b Mon Sep 17 00:00:00 2001 From: Dan Jones Date: Fri, 14 Nov 2025 15:11:26 -0600 Subject: [PATCH 4/8] =?UTF-8?q?=F0=9F=93=9D=20Rename=20example=20tests=20f?= =?UTF-8?q?or=20godoc=20visibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- example_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/example_test.go b/example_test.go index 5e30d21..00320d5 100644 --- a/example_test.go +++ b/example_test.go @@ -42,7 +42,7 @@ func Example() { // something went wrong in goroutine 2 } -func ExampleWaitErr_WaitForError() { +func Example_waitForError() { we := waiterr.New() we.Go(func() error { time.Sleep(100 * time.Millisecond) @@ -62,7 +62,7 @@ func ExampleWaitErr_WaitForError() { } -func ExampleWaitErr_Unwrap() { +func Example_unwrap() { we := waiterr.New() we.Go(func() error { time.Sleep(100 * time.Millisecond) From 76c3e62228fb388d0ec403e8e56111ede7ccd616 Mon Sep 17 00:00:00 2001 From: Dan Jones Date: Fri, 14 Nov 2025 15:14:43 -0600 Subject: [PATCH 5/8] =?UTF-8?q?=F0=9F=93=9D=20Add=20WithContext=20example?= =?UTF-8?q?=20to=20README.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/README.md b/README.md index e7923a0..f9f4688 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,42 @@ func main() { } ``` +### Using WithContext + +```go +package main + +import ( + "context" + "fmt" + "time" + + "codeberg.org/danjones000/waiterr" +) + +func main() { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + we, ctx := waiterr.WithContext(ctx) + + we.Go(func() error { + select { + case <-time.After(100 * time.Millisecond): + fmt.Println("Task completed") + return nil + case <-ctx.Done(): + fmt.Println("Task cancelled") + return ctx.Err() + } + }) + + _ = we.Wait() + // Output: + // Task completed +} +``` + ## Contributing Please refer to the [AGENTS.md](AGENTS.md) file for guidelines on contributing to this project, including code style, commit messages, and Git workflow. From 2970d636cd7b8cae3a28944e19377f5f28cf531b Mon Sep 17 00:00:00 2001 From: Dan Jones Date: Fri, 14 Nov 2025 15:22:12 -0600 Subject: [PATCH 6/8] =?UTF-8?q?=F0=9F=93=9D=20Add=20CONTRIBUTING.md=20for?= =?UTF-8?q?=20human=20contributors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CONTRIBUTING.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..4b8978c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,34 @@ +# Contributing to waiterr + +We welcome contributions to the `waiterr` project! Please take a moment to review these guidelines before submitting your contributions. + +## Reporting Bugs and Suggesting Features + +If you encounter a bug or have a feature request, please report it on our [Codeberg repository](https://codeberg.org/danjones000/waiterr/issues). + +## Git Flow Guidelines + +We follow a Git Flow branching model. + +* **`develop` branch**: This is our main integration branch for new features and bug fixes. +* **`stable` branch**: This branch contains the latest production-ready code. + +### Making Changes + +1. **Branching**: + * For new features or regular bug fixes, create a new branch from `develop` (e.g., `feat/your-feature-name` or `bug/your-bug-fix`). + * For urgent hotfixes addressing critical issues in `stable`, create a branch directly from `stable` (e.g., `hot/your-hotfix-name`). + +2. **Pull Requests (PRs)**: + * All new features and regular bug fixes should be submitted as Pull Requests targeting the `develop` branch. + * Hotfixes should be submitted as Pull Requests targeting the `stable` branch directly. After a hotfix is merged into `stable`, it must also be merged back into `develop`. + +3. **Commit Messages**: + * It's not *required* that you follow the [Gitmoji convention](https://gitmoji.dev/) for your commit messages, but it would make me happy if you did. 😏 + * Write clear, concise, and descriptive commit messages that explain *what* changed and *why*. + +## Code Style + +Please ensure your code adheres to the existing Go code style and formatting conventions used in the project. Run `go fmt ./...` and `go mod tidy` before submitting your changes. + +Thank you for contributing! From 0246d852b10ed2ee51b4ec950839fdae9b8742b0 Mon Sep 17 00:00:00 2001 From: Dan Jones Date: Fri, 14 Nov 2025 15:23:13 -0600 Subject: [PATCH 7/8] =?UTF-8?q?=F0=9F=93=9D=20Update=20README.md=20to=20re?= =?UTF-8?q?ference=20CONTRIBUTING.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f9f4688..d05ac57 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ func main() { ## Contributing -Please refer to the [AGENTS.md](AGENTS.md) file for guidelines on contributing to this project, including code style, commit messages, and Git workflow. +Please refer to the [CONTRIBUTING.md](CONTRIBUTING.md) file for guidelines on contributing to this project, including code style, commit messages, and Git workflow. ## License From 8eb46f8ea7c4f072b86d333132bfbeee4404813b Mon Sep 17 00:00:00 2001 From: Dan Jones Date: Fri, 14 Nov 2025 15:26:21 -0600 Subject: [PATCH 8/8] =?UTF-8?q?=F0=9F=94=96=20Release=20v1.0.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d00936..7f6acd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## v1.0.0 - 2025-11-14 + +### Added +- Refactored WaitErr into an interface and added a New constructor. +- Added WithContext function for context cancellation. +- Added CONTRIBUTING.md for human contributors. + +### Changed +- Updated README.md and example_test.go to reflect the new interface and WithContext function. +- Updated README.md to reference CONTRIBUTING.md. + + + ## v0.9.0 - 2025-11-13 ### Added