Compare commits

...

15 commits

12 changed files with 623 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
build/
.task/

46
.golangci.yaml Normal file
View file

@ -0,0 +1,46 @@
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
exclusions:
rules:
- path: _test\.go
linters:
- err113
- gocyclo
- gocognit

46
AGENTS.md Normal file
View file

@ -0,0 +1,46 @@
# 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 <TestName> <path/to/file_test.go>` (e.g., `go test -run TestNewHappy ./ezcache_test.go`)
- **Format:** `task fmt`
## Code Style Guidelines
- **Module**: `codeberg.org/danjones000/waiterr`
- **Go version**: 1.25.3
- **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`
- 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)
- **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

22
CHANGELOG.md Normal file
View file

@ -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.

21
LICENSE Normal file
View file

@ -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.

104
README.md Normal file
View file

@ -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.

55
Taskfile.yml Normal file
View file

@ -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

90
example_test.go Normal file
View file

@ -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
}

5
go.mod Normal file
View file

@ -0,0 +1,5 @@
module codeberg.org/danjones000/waiterr
go 1.25.3
require github.com/nalgeon/be v0.3.0

2
go.sum Normal file
View file

@ -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=

105
waiterr.go Normal file
View file

@ -0,0 +1,105 @@
package waiterr
import (
"errors"
"sync"
)
// 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
}
// 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)
})
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)
}
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. It will panic if called before Go.
func (we *WaitErr) WaitForError() error {
if we.errCh == nil {
panic("WaitForError called before Go")
}
// 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
// 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 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 {
if e != nil {
errs = append(errs, e)
}
}
if len(errs) == 0 {
return nil
}
return errs
}

125
waiterr_test.go Normal file
View file

@ -0,0 +1,125 @@
package waiterr_test
import (
"errors"
"testing"
"testing/synctest"
"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)
}
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)
})
}
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()
}
func TestWaitForErrorFirstErrSet(tt *testing.T) {
we := new(waiterr.WaitErr)
expectedErr := errors.New("pre-set error")
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()
we.Go(func() error { return errors.New("another error") })
synctest.Wait()
we.Go(func() error { return nil })
actualErr := we.WaitForError()
be.Err(t, actualErr, expectedErr)
})
}