Compare commits
13 commits
635126fc98
...
67eb618288
| Author | SHA1 | Date | |
|---|---|---|---|
| 67eb618288 | |||
| 0c3c39e504 | |||
| 06c15f8250 | |||
| 7a2583c8e4 | |||
| d49f548618 | |||
| 25b10a1896 | |||
| addd0d5e7b | |||
| 09b97e0b66 | |||
| 4d92c7484d | |||
| 5df1d9b7c9 | |||
| 2270f4c795 | |||
| 57e01555a9 | |||
| ba39b2d486 |
9 changed files with 364 additions and 13 deletions
|
|
@ -37,3 +37,10 @@ linters:
|
|||
settings:
|
||||
hugeParam:
|
||||
sizeThreshold: 255
|
||||
exclusions:
|
||||
rules:
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- err113
|
||||
- gocyclo
|
||||
- gocognit
|
||||
|
|
|
|||
|
|
@ -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:**
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
22
CHANGELOG.md
Normal file
22
CHANGELOG.md
Normal 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
21
LICENSE
Normal 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
104
README.md
Normal 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.
|
||||
90
example_test.go
Normal file
90
example_test.go
Normal 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
|
||||
}
|
||||
2
go.mod
2
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
|
||||
|
|
|
|||
69
waiterr.go
69
waiterr.go
|
|
@ -5,18 +5,40 @@ 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
|
||||
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 [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)
|
||||
|
|
@ -25,12 +47,37 @@ 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 {
|
||||
// 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")
|
||||
}
|
||||
// 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
|
||||
|
|
@ -43,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 {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package waiterr_test
|
|||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
|
||||
"github.com/nalgeon/be"
|
||||
|
||||
|
|
@ -64,3 +65,61 @@ 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)
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue