From 8c595a9080f5a77de356df252a406032b6ffd7d0 Mon Sep 17 00:00:00 2001 From: Dan Jones Date: Thu, 13 Nov 2025 11:52:56 -0600 Subject: [PATCH 01/14] =?UTF-8?q?=E2=9C=A8=20Initial=20project=20setup=20w?= =?UTF-8?q?ith=20basic=20scaffolding,=20build=20tools,=20and=20agent=20gui?= =?UTF-8?q?delines.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + .golangci.yaml | 39 ++++++++++++++++++++++++++++++++ .task/checksum/fmt | 1 + AGENTS.md | 43 ++++++++++++++++++++++++++++++++++++ Taskfile.yml | 55 ++++++++++++++++++++++++++++++++++++++++++++++ go.mod | 3 +++ 6 files changed, 142 insertions(+) create mode 100644 .gitignore create mode 100644 .golangci.yaml create mode 100644 .task/checksum/fmt create mode 100644 AGENTS.md create mode 100644 Taskfile.yml create mode 100644 go.mod diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..567609b --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..3f33683 --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,39 @@ +version: "2" + +linters: + enable: + - errcheck + - govet + - ineffassign + - staticcheck + - unused + - copyloopvar + - dupl + - err113 + - errname + - exptostd + - fatcontext + - funlen + - gocognit + - goconst + - gocritic + - gocyclo + - godot + - godox + - gosec + - perfsprint + - testifylint + settings: + testifylint: + enable-all: true + disable: + - require-error + gocognit: + min-complexity: 16 + gocyclo: + min-complexity: 15 + gocritic: + enable-all: true + settings: + hugeParam: + sizeThreshold: 255 diff --git a/.task/checksum/fmt b/.task/checksum/fmt new file mode 100644 index 0000000..c950c84 --- /dev/null +++ b/.task/checksum/fmt @@ -0,0 +1 @@ +99aa06d3014798d86001c324468d497f diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..46e2024 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,43 @@ +# Agent Guidelines for waiterr + +This document outlines the conventions and commands for agents operating within the `waiterr` Go project. + +## Build/Lint/Test Commands + +- **Lint:** `task lint` +- **Test All:** `task test` +- **Test Single File:** `go test -run ` (e.g., `go test -run TestNewHappy ./ezcache_test.go`) +- **Format:** `task fmt` + +## Code Style Guidelines + +- **Module**: `codeberg.org/danjones000/waiterr` +- **Go version**: 1.24.9 +- **Imports:** Group standard library imports separately from third-party imports. +- **Formatting:** Adhere to `go fmt` standards. +- **Naming Conventions:** + - Variables: `camelCase` + - Functions/Methods: `CamelCase` (exported), `camelCase` (unexported) + - Packages: `lowercase` +- **Error Handling:** Return errors explicitly. Check errors immediately after a function call that returns an error. +- **Linter Rules:** Refer to `.golangci.yaml` for detailed linting rules. +- **Testing**: Use `github.com/nalgeon/be` + +## Git Commit Guidelines +- **Format**: Prepend commit messages with a gitmoji emoji (see https://gitmoji.dev) +- **Style**: Write detailed commit messages that explain what changed and why +- **Examples**: `✨ Add JSON export functionality for log entries`, `🐛 Fix date parsing for RFC3339 timestamps`, `📝 Update README with configuration examples` + +## Git Flow Workflow +- **Main branches**: `stable` (production-ready), `develop` (integration branch) +- **Development**: Always commit new features/fixes to `develop` branch or appropriate feature branches +- **Branch prefixes**: + - `feat/feature-name` - New features, merge to `develop` when complete + - `bug/bug-name` - Bug fixes (non-urgent), merge to `develop` when complete + - `rel/version` - Release preparation branches, merge to `stable` and then **also** merge `stable` back to `develop` + - `hot/version` - Hotfixes for production issues follow same merge rules as releases +- **Version tags**: Prefix all version tags with `v` (e.g., `v1.0.2`, `v0.0.6`) +- **Releases**: Update CHANGELOG.md with a summary of changes for each new version +- **Never commit directly to** `stable` branch (only merge from `rel/` or `hot/` branches) +- After merging to `stable`, always merge it back to `develop` +- **Before starting work**: Ensure you're on `develop` branch or create an appropriate feature branch from it diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..049fc9a --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,55 @@ +version: '3' + +tasks: + default: + cmds: + - task: fmt + - task: test + - task: lint + + fmt: + desc: Format go code + sources: + - '**/*.go' + cmds: + - go fmt ./... + - go mod tidy + + lint: + desc: Do static analysis + sources: + - '**/*.go' + cmds: + - golangci-lint run + + test: + desc: Run unit tests + deps: [fmt] + sources: + - '**/*.go' + generates: + - build/cover.out + cmds: + - go test -race -cover -coverprofile build/cover.out ./... + + coverage-report: + desc: Build coverage report + deps: [test] + sources: + - build/cover.out + generates: + - build/cover.html + cmds: + - go tool cover -html=build/cover.out -o build/cover.html + + serve-report: + desc: Serve the coverage report + deps: [coverage-report] + cmds: + - ip addr list | grep inet + - python3 -m http.server -d build 3434 + + serve-docs: + desc: Serve the current docs + cmds: + - godoc -http=0.0.0.0:3434 -play diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e24fc8f --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module codeberg.org/danjones000/waiterr + +go 1.24.9 From 635126fc980270fa46f5434829d8f298e4b48010 Mon Sep 17 00:00:00 2001 From: Dan Jones Date: Thu, 13 Nov 2025 14:01:30 -0600 Subject: [PATCH 02/14] =?UTF-8?q?=E2=9C=A8=20Implement=20core=20waiterr=20?= =?UTF-8?q?package=20with=20initial=20functionality=20and=20tests.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + .task/checksum/fmt | 1 - AGENTS.md | 4 ++- go.mod | 2 ++ go.sum | 2 ++ waiterr.go | 58 ++++++++++++++++++++++++++++++++++++++++ waiterr_test.go | 66 ++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 132 insertions(+), 2 deletions(-) delete mode 100644 .task/checksum/fmt create mode 100644 go.sum create mode 100644 waiterr.go create mode 100644 waiterr_test.go diff --git a/.gitignore b/.gitignore index 567609b..c525b60 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ build/ +.task/ diff --git a/.task/checksum/fmt b/.task/checksum/fmt deleted file mode 100644 index c950c84..0000000 --- a/.task/checksum/fmt +++ /dev/null @@ -1 +0,0 @@ -99aa06d3014798d86001c324468d497f diff --git a/AGENTS.md b/AGENTS.md index 46e2024..33ff925 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,9 @@ This document outlines the conventions and commands for agents operating within - Packages: `lowercase` - **Error Handling:** Return errors explicitly. Check errors immediately after a function call that returns an error. - **Linter Rules:** Refer to `.golangci.yaml` for detailed linting rules. -- **Testing**: Use `github.com/nalgeon/be` +- **Testing**: + - Use `github.com/nalgeon/be` + - Tests should be in a separate package, such as waiterr_test ## Git Commit Guidelines - **Format**: Prepend commit messages with a gitmoji emoji (see https://gitmoji.dev) diff --git a/go.mod b/go.mod index e24fc8f..6d6bf2f 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module codeberg.org/danjones000/waiterr go 1.24.9 + +require github.com/nalgeon/be v0.3.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..bd32e32 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/nalgeon/be v0.3.0 h1:QsPANqEtcOD5qT2S3KAtIkDBBn8SXUf/Lb5Bi/z4UqM= +github.com/nalgeon/be v0.3.0/go.mod h1:PMwMuBLopwKJkSHnr2qHyLcZYUTqNejN7A8RAqNWO3E= diff --git a/waiterr.go b/waiterr.go new file mode 100644 index 0000000..3da73bf --- /dev/null +++ b/waiterr.go @@ -0,0 +1,58 @@ +package waiterr + +import ( + "errors" + "sync" +) + +// WaitErr wraps a sync.WaitGroup with error handling. +type WaitErr struct { + wg sync.WaitGroup + errs []error + mut sync.RWMutex +} + +// Go runs f in its own goroutine. When f returns, its error is stored, and returned +// with we.Wait() +func (we *WaitErr) Go(f func() error) { + wrap := func() { + err := f() + we.mut.Lock() + defer we.mut.Unlock() + we.errs = append(we.errs, err) + } + we.wg.Go(wrap) +} + +// 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. +func (we *WaitErr) WaitForError() error { + // Implement this + // If we already have an error, return it immediately without waiting + // If no error has yet been returned, wait for the very first error and return it + return nil +} + +// 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 { + we.wg.Wait() + we.mut.RLock() + defer we.mut.RUnlock() + return errors.Join(we.errs...) +} + +// Unwrap returns all non-nil errors returned by our functions. +// If we.errs is empty, or all errors are nil, just return nil. +func (we *WaitErr) Unwrap() []error { + errs := make([]error, 0, len(we.errs)) + for _, e := range we.errs { + if e != nil { + errs = append(errs, e) + } + } + if len(errs) == 0 { + return nil + } + return errs +} diff --git a/waiterr_test.go b/waiterr_test.go new file mode 100644 index 0000000..293f7dc --- /dev/null +++ b/waiterr_test.go @@ -0,0 +1,66 @@ +package waiterr_test + +import ( + "errors" + "testing" + + "github.com/nalgeon/be" + + "codeberg.org/danjones000/waiterr" +) + +func TestGo(t *testing.T) { + we := new(waiterr.WaitErr) + err := errors.New("uh-oh") + var run bool + we.Go(func() error { + run = true + return err + }) + + be.Err(t, we.Wait(), err) + be.True(t, run) +} + +func TestWait(t *testing.T) { + we := new(waiterr.WaitErr) + er1 := errors.New("uh-oh") + er2 := errors.New("oops") + we.Go(func() error { return er1 }) + we.Go(func() error { return nil }) + we.Go(func() error { return er2 }) + + err := we.Wait() + be.Err(t, err, er1, er2) + if ers, ok := err.(interface{ Unwrap() []error }); ok { + all := ers.Unwrap() + be.Equal(t, len(all), 2) + be.True(t, all[0] == er1 || all[0] == er2) + be.True(t, all[1] == er2 || all[1] == er1) + } else { + t.Fatal("Returned error should have Unwrap method") + } +} + +func TestWaitForError(t *testing.T) { + we := new(waiterr.WaitErr) + er1 := errors.New("uh-oh") + er2 := errors.New("oops") + we.Go(func() error { return nil }) + we.Go(func() error { return er1 }) + we.Go(func() error { return er2 }) + + err := we.WaitForError() + // Due to how goroutines run, it is possible that either of those return first. This is an acceptable limitation + be.True(t, err == er1 || err == er2) +} + +func TestWaitForErrorNoErr(t *testing.T) { + we := new(waiterr.WaitErr) + we.Go(func() error { return nil }) + we.Go(func() error { return nil }) + we.Go(func() error { return nil }) + + err := we.WaitForError() + be.Err(t, err, nil) +} From ba39b2d48695af03b7cf3ff9edb5cb6a2e2801c8 Mon Sep 17 00:00:00 2001 From: Dan Jones Date: Thu, 13 Nov 2025 14:26:49 -0600 Subject: [PATCH 03/14] =?UTF-8?q?=E2=9A=99=EF=B8=8F=20Update=20Go=20versio?= =?UTF-8?q?n=20and=20linter=20exclusions=20for=20test=20files.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .golangci.yaml | 7 +++++++ AGENTS.md | 2 +- go.mod | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.golangci.yaml b/.golangci.yaml index 3f33683..31104b0 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -37,3 +37,10 @@ linters: settings: hugeParam: sizeThreshold: 255 + exclusions: + rules: + - path: _test\.go + linters: + - err113 + - gocyclo + - gocognit diff --git a/AGENTS.md b/AGENTS.md index 33ff925..07e84ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ This document outlines the conventions and commands for agents operating within ## Code Style Guidelines - **Module**: `codeberg.org/danjones000/waiterr` -- **Go version**: 1.24.9 +- **Go version**: 1.25.3 - **Imports:** Group standard library imports separately from third-party imports. - **Formatting:** Adhere to `go fmt` standards. - **Naming Conventions:** diff --git a/go.mod b/go.mod index 6d6bf2f..31708ab 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,5 @@ module codeberg.org/danjones000/waiterr -go 1.24.9 +go 1.25.3 require github.com/nalgeon/be v0.3.0 From 57e01555a92661551ab25af49f53af34592a73bd Mon Sep 17 00:00:00 2001 From: Dan Jones Date: Thu, 13 Nov 2025 14:38:43 -0600 Subject: [PATCH 04/14] =?UTF-8?q?=F0=9F=93=9D=20Update=20AGENTS.md=20with?= =?UTF-8?q?=20new=20`sync.WaitGroup`=20guidelines.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index 07e84ff..7193d67 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,7 @@ This document outlines the conventions and commands for agents operating within - **Testing**: - Use `github.com/nalgeon/be` - Tests should be in a separate package, such as waiterr_test +- **sync.WaitGroup**: Do not use the `WaitGroup.Add` or `WaitGroup.Done` functions. Instead, we should rely on `WaitGroup.Go` to spawn new goroutines. ## Git Commit Guidelines - **Format**: Prepend commit messages with a gitmoji emoji (see https://gitmoji.dev) From 2270f4c795152f2466d9a695ae16ce2c10c2569b Mon Sep 17 00:00:00 2001 From: Dan Jones Date: Thu, 13 Nov 2025 14:44:07 -0600 Subject: [PATCH 05/14] =?UTF-8?q?=E2=9C=A8=20Implement=20waiterr.WaitForEr?= =?UTF-8?q?ror?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- waiterr.go | 63 +++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 55 insertions(+), 8 deletions(-) diff --git a/waiterr.go b/waiterr.go index 3da73bf..9e21c26 100644 --- a/waiterr.go +++ b/waiterr.go @@ -7,16 +7,38 @@ import ( // WaitErr wraps a sync.WaitGroup with error handling. type WaitErr struct { - wg sync.WaitGroup - errs []error - mut sync.RWMutex + wg sync.WaitGroup + errs []error + mut sync.RWMutex + firstErr error + firstErrOnce sync.Once + errCh chan error // Buffered channel of size 1 + initErrChOnce sync.Once } // Go runs f in its own goroutine. When f returns, its error is stored, and returned -// with we.Wait() +// with we.Wait(). func (we *WaitErr) Go(f func() error) { + we.initErrChOnce.Do(func() { + we.errCh = make(chan error, 1) + }) wrap := func() { err := f() + + if err != nil { + we.firstErrOnce.Do(func() { + we.mut.Lock() // Acquire lock before writing to firstErr + we.firstErr = err + we.mut.Unlock() // Release lock after writing + + // Non-blocking send to errCh + select { + case we.errCh <- err: + default: + } + }) + } + we.mut.Lock() defer we.mut.Unlock() we.errs = append(we.errs, err) @@ -27,10 +49,35 @@ 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. func (we *WaitErr) WaitForError() error { - // Implement this - // If we already have an error, return it immediately without waiting - // If no error has yet been returned, wait for the very first error and return it - return nil + if we.errCh == nil { + panic("WaitForError called before Go, errCh is nil") + } + // Check if an error has already been set + we.mut.RLock() + if we.firstErr != nil { + err := we.firstErr + we.mut.RUnlock() + return err + } + we.mut.RUnlock() + + // Create a channel to signal when all goroutines are done + done := make(chan struct{}) + go func() { + we.wg.Wait() + close(done) + }() + + select { + case err := <-we.errCh: + return err + case <-done: + // All goroutines finished, and no error was sent to errCh + // Re-check firstErr in case it was set just before 'done' was closed + we.mut.RLock() + defer we.mut.RUnlock() + return we.firstErr // This will be nil if no error occurred + } } // Wait for all current goroutines to finish. Return an error that combines all errors returned From 5df1d9b7c9bcefe024605300736212ae47c7242d Mon Sep 17 00:00:00 2001 From: Dan Jones Date: Thu, 13 Nov 2025 14:47:36 -0600 Subject: [PATCH 06/14] =?UTF-8?q?=F0=9F=93=84=20Add=20MIT=20License?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- LICENSE | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d0b013e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Dan Jones + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From 4d92c7484d40ede16c1cee650361fa25bbdb9eed Mon Sep 17 00:00:00 2001 From: Dan Jones Date: Thu, 13 Nov 2025 14:53:54 -0600 Subject: [PATCH 07/14] =?UTF-8?q?=F0=9F=93=9D=20Update=20WaitForError=20co?= =?UTF-8?q?mment=20and=20add=20README.md=20for=20project=20documentation.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 104 +++++++++++++++++++++++++++++++++++++++++++++++++++++ waiterr.go | 2 +- 2 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..b503e38 --- /dev/null +++ b/README.md @@ -0,0 +1,104 @@ +# waiterr + +`waiterr` is a Go package that wraps `sync.WaitGroup` with enhanced error handling capabilities. It allows you to run multiple goroutines, collect any errors they return, and wait for their completion, either returning the first error encountered or aggregating all errors. + +## Features + +- **`Go(f func() error)`**: Runs a function `f` in a new goroutine, storing any error it returns. +- **`Wait() error`**: Waits for all goroutines to complete and returns a combined error of all non-nil errors. +- **`WaitForError() error`**: Waits for the first error to be returned by any goroutine and immediately returns that error. If all goroutines complete without error, it returns `nil`. +- **`Unwrap() []error`**: Returns a slice of all non-nil errors encountered by the goroutines. + +## Installation + +To install `waiterr`, use `go get`: + +```bash +go get codeberg.org/danjones000/waiterr +``` + +## Usage + +Here's a basic example of how to use `waiterr`: + +```go +package main + +import ( + "errors" + "fmt" + "time" + + "codeberg.org/danjones000/waiterr" +) + +func main() { + we := new(waiterr.WaitErr) + + we.Go(func() error { + time.Sleep(100 * time.Millisecond) + fmt.Println("Goroutine 1 finished") + return nil + }) + + we.Go(func() error { + time.Sleep(50 * time.Millisecond) + fmt.Println("Goroutine 2 finished with an error") + return errors.New("something went wrong in goroutine 2") + }) + + we.Go(func() error { + time.Sleep(150 * time.Millisecond) + fmt.Println("Goroutine 3 finished") + return nil + }) + + // Wait for all goroutines and get all errors + if err := we.Wait(); err != nil { + fmt.Printf("All goroutines finished. Combined error: %v +", err) + } + + // You can also get the first error immediately + we2 := new(waiterr.WaitErr) + we2.Go(func() error { + time.Sleep(100 * time.Millisecond) + return errors.New("first error from we2") + }) + we2.Go(func() error { + time.Sleep(50 * time.Millisecond) + return errors.New("second error from we2") + }) + + if err := we2.WaitForError(); err != nil { + fmt.Printf("First error from we2: %v +", err) + } + + // Get all unwrapped errors + unwrappedErrors := we.Unwrap() + if len(unwrappedErrors) > 0 { + fmt.Println("Unwrapped errors:") + for i, err := range unwrappedErrors { + fmt.Printf(" %d: %v +", i+1, err) + } + } +} +``` + +## 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. + +## License + +This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. + +## Go Version + +Go 1.25.3 + +## Dependencies + +- `github.com/nalgeon/be v0.3.0` for testing. diff --git a/waiterr.go b/waiterr.go index 9e21c26..b846952 100644 --- a/waiterr.go +++ b/waiterr.go @@ -47,7 +47,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. +// 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, errCh is nil") From 09b97e0b6614a65ccb2a14696e37921380788a89 Mon Sep 17 00:00:00 2001 From: Dan Jones Date: Thu, 13 Nov 2025 15:07:13 -0600 Subject: [PATCH 08/14] =?UTF-8?q?=E2=9C=85=20Add=20tests=20for=20Unwrap=20?= =?UTF-8?q?method?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- waiterr_test.go | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/waiterr_test.go b/waiterr_test.go index 293f7dc..3c74feb 100644 --- a/waiterr_test.go +++ b/waiterr_test.go @@ -64,3 +64,31 @@ func TestWaitForErrorNoErr(t *testing.T) { err := we.WaitForError() be.Err(t, err, nil) } + +func TestUnwrap(tt *testing.T) { + tt.Run("two errors", func(t *testing.T) { + we := new(waiterr.WaitErr) + er1 := errors.New("error one") + er2 := errors.New("error two") + + we.Go(func() error { return er1 }) + we.Go(func() error { return nil }) + we.Go(func() error { return er2 }) + we.Go(func() error { return nil }) + + _ = we.Wait() // Ensure all goroutines complete + + unwrapped := we.Unwrap() + be.Equal(t, len(unwrapped), 2) + be.True(t, (unwrapped[0] == er1 && unwrapped[1] == er2) || (unwrapped[0] == er2 && unwrapped[1] == er1)) + + }) + + tt.Run("no errors", func(t *testing.T) { + weNoErr := new(waiterr.WaitErr) + weNoErr.Go(func() error { return nil }) + weNoErr.Go(func() error { return nil }) + _ = weNoErr.Wait() + be.Equal(t, weNoErr.Unwrap(), nil) + }) +} From addd0d5e7b83ce528a313daccc6796275f4997b2 Mon Sep 17 00:00:00 2001 From: Dan Jones Date: Thu, 13 Nov 2025 15:23:18 -0600 Subject: [PATCH 09/14] =?UTF-8?q?=E2=9C=85=20Add=20test=20for=20WaitForErr?= =?UTF-8?q?or=20panic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- waiterr.go | 2 +- waiterr_test.go | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/waiterr.go b/waiterr.go index b846952..156da4a 100644 --- a/waiterr.go +++ b/waiterr.go @@ -50,7 +50,7 @@ func (we *WaitErr) Go(f func() error) { // 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, errCh is nil") + panic("WaitForError called before Go") } // Check if an error has already been set we.mut.RLock() diff --git a/waiterr_test.go b/waiterr_test.go index 3c74feb..a2691be 100644 --- a/waiterr_test.go +++ b/waiterr_test.go @@ -92,3 +92,14 @@ func TestUnwrap(tt *testing.T) { be.Equal(t, weNoErr.Unwrap(), nil) }) } + +func TestWaitForErrorPanic(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Errorf("The code did not panic") + } + }() + + we := new(waiterr.WaitErr) + _ = we.WaitForError() +} From 25b10a18960ffc3d3e9255b2d5d6226c0a11ff2f Mon Sep 17 00:00:00 2001 From: Dan Jones Date: Thu, 13 Nov 2025 15:25:50 -0600 Subject: [PATCH 10/14] =?UTF-8?q?=E2=9C=85=20Add=20test=20for=20WaitForErr?= =?UTF-8?q?or=20when=20firstErr=20is=20already=20set?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- waiterr_test.go | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/waiterr_test.go b/waiterr_test.go index a2691be..6e663a3 100644 --- a/waiterr_test.go +++ b/waiterr_test.go @@ -103,3 +103,23 @@ func TestWaitForErrorPanic(t *testing.T) { we := new(waiterr.WaitErr) _ = we.WaitForError() } + +func TestWaitForErrorFirstErrSet(t *testing.T) { + we := new(waiterr.WaitErr) + expectedErr := errors.New("pre-set error") + + // Manually set firstErr (requires reflection or a way to access unexported fields) + // For testing purposes, we can simulate this by calling Go with an error first + we.Go(func() error { return expectedErr }) + we.Go(func() error { return errors.New("another error") }) + we.Go(func() error { return nil }) + + // Wait for a short period to ensure the first error is processed + // In a real scenario, this might be handled by a channel or more robust synchronization + // For this test, we'll rely on the Go routine scheduling to set firstErr quickly + _ = we.WaitForError() // Call once to ensure firstErr is set internally + + // Now call WaitForError again, expecting it to return the already set firstErr + actualErr := we.WaitForError() + be.Err(t, actualErr, expectedErr) +} From d49f548618c3001143b1d5d3eb6b6425937720d0 Mon Sep 17 00:00:00 2001 From: Dan Jones Date: Thu, 13 Nov 2025 15:37:23 -0600 Subject: [PATCH 11/14] =?UTF-8?q?=F0=9F=93=9D=20Update=20comments=20in=20w?= =?UTF-8?q?aiterr.go?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- waiterr.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/waiterr.go b/waiterr.go index 156da4a..0ba8821 100644 --- a/waiterr.go +++ b/waiterr.go @@ -5,7 +5,7 @@ import ( "sync" ) -// WaitErr wraps a sync.WaitGroup with error handling. +// WaitErr wraps a [sync.WaitGroup] with error handling. type WaitErr struct { wg sync.WaitGroup errs []error @@ -17,7 +17,7 @@ type WaitErr struct { } // Go runs f in its own goroutine. When f returns, its error is stored, and returned -// with we.Wait(). +// with [WaitErr.Wait]. func (we *WaitErr) Go(f func() error) { we.initErrChOnce.Do(func() { we.errCh = make(chan error, 1) @@ -90,7 +90,7 @@ func (we *WaitErr) Wait() error { } // Unwrap returns all non-nil errors returned by our functions. -// If we.errs is empty, or all errors are nil, just return nil. +// If no errors were returned, or all errors are nil, it returns nil. func (we *WaitErr) Unwrap() []error { errs := make([]error, 0, len(we.errs)) for _, e := range we.errs { From 7a2583c8e4d4d7d1c91a59cd9ac0febf1e8386b9 Mon Sep 17 00:00:00 2001 From: Dan Jones Date: Thu, 13 Nov 2025 16:01:53 -0600 Subject: [PATCH 12/14] =?UTF-8?q?=F0=9F=90=9B=20Fix=20race=20condition=20i?= =?UTF-8?q?n=20TestWaitForErrorFirstErrSet=20using=20synctest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- waiterr_test.go | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/waiterr_test.go b/waiterr_test.go index 6e663a3..6f570a0 100644 --- a/waiterr_test.go +++ b/waiterr_test.go @@ -3,6 +3,7 @@ package waiterr_test import ( "errors" "testing" + "testing/synctest" "github.com/nalgeon/be" @@ -104,22 +105,21 @@ func TestWaitForErrorPanic(t *testing.T) { _ = we.WaitForError() } -func TestWaitForErrorFirstErrSet(t *testing.T) { +func TestWaitForErrorFirstErrSet(tt *testing.T) { we := new(waiterr.WaitErr) expectedErr := errors.New("pre-set error") - // Manually set firstErr (requires reflection or a way to access unexported fields) - // For testing purposes, we can simulate this by calling Go with an error first - we.Go(func() error { return expectedErr }) - we.Go(func() error { return errors.New("another error") }) - we.Go(func() error { return nil }) + synctest.Test(tt, func(t *testing.T) { + we.Go(func() error { return expectedErr }) + // synctest.Wait ensures that the gorouting has finished before anything else. + synctest.Wait() - // Wait for a short period to ensure the first error is processed - // In a real scenario, this might be handled by a channel or more robust synchronization - // For this test, we'll rely on the Go routine scheduling to set firstErr quickly - _ = we.WaitForError() // Call once to ensure firstErr is set internally + we.Go(func() error { return errors.New("another error") }) + synctest.Wait() - // Now call WaitForError again, expecting it to return the already set firstErr - actualErr := we.WaitForError() - be.Err(t, actualErr, expectedErr) + we.Go(func() error { return nil }) + + actualErr := we.WaitForError() + be.Err(t, actualErr, expectedErr) + }) } From 06c15f8250ffddb3d9730fd2cc6abfb4a5151f7b Mon Sep 17 00:00:00 2001 From: Dan Jones Date: Thu, 13 Nov 2025 17:07:55 -0600 Subject: [PATCH 13/14] =?UTF-8?q?=E2=9C=A8=20Add=20examples=20for=20WaitEr?= =?UTF-8?q?r=20methods?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- example_test.go | 90 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 example_test.go diff --git a/example_test.go b/example_test.go new file mode 100644 index 0000000..3246ddd --- /dev/null +++ b/example_test.go @@ -0,0 +1,90 @@ +package waiterr_test + +import ( + "errors" + "fmt" + "time" + + "codeberg.org/danjones000/waiterr" +) + +func Example() { + we := new(waiterr.WaitErr) + + we.Go(func() error { + time.Sleep(100 * time.Millisecond) + fmt.Println("Goroutine 1 finished") + return nil + }) + + we.Go(func() error { + time.Sleep(50 * time.Millisecond) + fmt.Println("Goroutine 2 finished with an error") + return errors.New("something went wrong in goroutine 2") + }) + + we.Go(func() error { + time.Sleep(150 * time.Millisecond) + fmt.Println("Goroutine 3 finished") + return nil + }) + + // Wait for all goroutines and get all errors + if err := we.Wait(); err != nil { + fmt.Printf("All goroutines finished. Combined error:\n%v\n", err) + } + + // Output: + // Goroutine 2 finished with an error + // Goroutine 1 finished + // Goroutine 3 finished + // All goroutines finished. Combined error: + // something went wrong in goroutine 2 +} + +func ExampleWaitErr_WaitForError() { + we := new(waiterr.WaitErr) + we.Go(func() error { + time.Sleep(100 * time.Millisecond) + return errors.New("first error from we") + }) + we.Go(func() error { + time.Sleep(50 * time.Millisecond) + return errors.New("second error from we") + }) + + if err := we.WaitForError(); err != nil { + fmt.Printf("First error returned from we: %v\n", err) + } + + // Output: + // First error returned from we: second error from we + +} + +func ExampleWaitErr_Unwrap() { + we := new(waiterr.WaitErr) + we.Go(func() error { + time.Sleep(100 * time.Millisecond) + return errors.New("first error from we") + }) + we.Go(func() error { + time.Sleep(50 * time.Millisecond) + return errors.New("second error from we") + }) + we.Go(func() error { + time.Sleep(75 * time.Millisecond) + return nil + }) + + _ = we.Wait() + + errs := we.Unwrap() + for _, e := range errs { + fmt.Println(e) + } + + // Output: + // second error from we + // first error from we +} From 0c3c39e504654b83710511e1d4f59a0ae8ed44b2 Mon Sep 17 00:00:00 2001 From: Dan Jones Date: Thu, 13 Nov 2025 17:10:20 -0600 Subject: [PATCH 14/14] =?UTF-8?q?=F0=9F=93=9D=20Add=20CHANGELOG.md=20for?= =?UTF-8?q?=20v0.9.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9d00936 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,22 @@ +# Changelog + +## v0.9.0 - 2025-11-13 + +### Added +- Add examples for WaitErr methods +- Add test for WaitForError when firstErr is already set +- Add test for WaitForError panic +- Add tests for Unwrap method +- Add MIT License +- Implement waiterr.WaitForError +- Implement core waiterr package with initial functionality and tests. +- Initial project setup with basic scaffolding, build tools, and agent guidelines. + +### Fixed +- Fix race condition in TestWaitForErrorFirstErrSet using synctest + +### Changed +- Update comments in waiterr.go +- Update WaitForError comment and add README.md for project documentation. +- Update AGENTS.md with new `sync.WaitGroup` guidelines. +- Update Go version and linter exclusions for test files.