Skip to content

Commit

Permalink
Implement ErrorGroup and update tests and examples
Browse files Browse the repository at this point in the history
  • Loading branch information
sergerad committed Nov 21, 2023
1 parent 396c81b commit 1a17854
Show file tree
Hide file tree
Showing 10 changed files with 221 additions and 133 deletions.
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# CHANGELOG

## v2.0.0

*

## v1.0.0

* Initial implementation
16 changes: 8 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,30 +24,30 @@ Instantiate the error group and main context in the main goroutine.

```Go
func main() {
mainCtx, cancel := relax.MainContext()
mainCtx := relax.Context()
```
This ensures that the main goroutine is relaxed against SIGINT and SIGTERM.
This ensures that SIGINT/SIGTERM will cause all contexts used in the application to be `Done()`.
If you have multiple, long running processes to run in your program, you can use errgroup to launch them.
If you have multiple, long running processes to run in your program, you can use ErrorGroup to launch them.
```Go
g, ctx := errgroup.WithContext(mainCtx)
g, ctx := relax.NewErrorGroup(mainCtx)
```
Launch your goroutines and make sure to `defer relax.Recover(cancel)` so that any panics do not get in the way of graceful shutdown of the program.
You can use the `ErrorGroup` to launch goroutines which will return an error if they encounter a panic.
```Go
g.Go(func() error {
defer relax.Recover(cancel)
return myLongRunningProcess(ctx)
// An error containing "failed" will be returned
panic("failed")
})
```
Finally, in the main goroutine, make sure to wait for the error group:
```Go
if err := g.Wait(); err != nil {
fmt.Println("error from group", err)
fmt.Println(err)
}
```
Expand Down
31 changes: 31 additions & 0 deletions context.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package relax

import (
"context"
"os"
"os/signal"
"syscall"
)

// Context instantiates a context that is cancelled only when
// the SIGINT/SIGTERM signals are received.
// This context should be set up in main() of your application.
func Context() context.Context {
// Instantiate cancellable context
ctx, cancel := context.WithCancel(context.Background())
go func() {
// Cancel context on signals
signalChan := make(chan os.Signal, 1)
signal.Notify(signalChan, Signals()...)
// Wait for signal
<-signalChan
// Cancel context
cancel()
}()
return ctx
}

// Signals returns the signals that will cause the context to be cancelled.
func Signals() []os.Signal {
return []os.Signal{syscall.SIGINT, syscall.SIGTERM}
}
27 changes: 27 additions & 0 deletions context_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package relax

import (
"fmt"
"os"
"os/exec"
"testing"

"github.com/stretchr/testify/require"
)

func TestContext_Signals_CancelContext(t *testing.T) {
for _, signal := range Signals() {
t.Run(signal.String(), func(t *testing.T) {
ctx := Context()
go func() {
cmd := exec.Command("kill", "-SIGINT", fmt.Sprint(os.Getpid()))
cmd.Stderr = os.Stderr
cmd.Stdout = os.Stdout
require.NoError(t, cmd.Run())
}()
// Exit only if signal causes cancel
<-ctx.Done()
t.Log("Exiting gracefully")
})
}
}
48 changes: 48 additions & 0 deletions errgroup.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package relax

import (
"context"
"fmt"

"golang.org/x/sync/errgroup"
)

var (
// PanicError is returned when a panic is recovered
// during the execution of a goroutine
PanicError = fmt.Errorf("recovered from panic")
)

// ErrorGroup is a wrapper around errgroup.Group that
// recovers from panics and returns them as errors
type ErrorGroup struct {
*errgroup.Group
}

// NewErrorGroup instantiates an ErrorGroup and corresponding context.
// This function should be used the same way as errgroup.WithContext() from
// golang.org/x/sync/errgroup
func NewErrorGroup(ctx context.Context) (*ErrorGroup, context.Context) {
errgroup, groupCtx := errgroup.WithContext(ctx)
return &ErrorGroup{
Group: errgroup,
}, groupCtx
}

// Go runs a provided func in a goroutine while ensuring that
// any panic is recovered and returned as an error
func (g *ErrorGroup) Go(f func() error) {
g.Group.Go(func() (err error) {
// Define a recover func that converts a panic to an error
recoverFunc := func() {
if r := recover(); r != nil {
// Assign the panic content to returned error
err = fmt.Errorf("%w: %v", PanicError, r)
}
}
// Handle panics
defer recoverFunc()
// Call the provided func
return f()
})
}
64 changes: 64 additions & 0 deletions errgroup_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package relax

import (
"context"
"errors"
"testing"

"github.com/stretchr/testify/assert"
)

func TestErrGroup_CancelParentContext_ChildContextDone(t *testing.T) {
// Root context to cancel
ctx, cancel := context.WithCancel(context.Background())

// Group to wait on root and group context Done()
e, gCtx := NewErrorGroup(ctx)
e.Go(func() error {
<-ctx.Done()
<-gCtx.Done()
return nil
})

// Cancel root context
cancel()

// Validate
assert.NoError(t, e.Wait())
}

func TestErrGroup_Panic_Error(t *testing.T) {
// Group
e, ctx := NewErrorGroup(context.Background())

// Routine that panics
panicMsg := "testing content is returned as error"
e.Go(func() error {
panic(panicMsg)
})

// Sibling routine that blocks on Group context
siblingChan := make(chan interface{}, 1)
e.Go(func() error {
<-ctx.Done()
siblingChan <- nil
return nil
})

// Wait for panic goroutine, verify error
err := e.Wait()
assert.Error(t, err)
assert.Contains(t, err.Error(), panicMsg)
assert.True(t, errors.Is(err, PanicError))

// Verify sibling finished
assert.Nil(t, <-siblingChan)
}

func TestErrGroup_NoPanic_NoError(t *testing.T) {
e, _ := NewErrorGroup(context.Background())
e.Go(func() error {
return nil
})
assert.NoError(t, e.Wait())
}
27 changes: 27 additions & 0 deletions examples/context/main.go.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package main

import (
"fmt"
"os"
"os/exec"

"github.com/sergerad/relax"
)

func main() {
ctx := relax.Context()
// Exit only if signal causes cancel
println("Either of the following commands would exit the app gracefully:")
println("kill -SIGINT", os.Getpid())
println("kill -SIGTERM", os.Getpid())
go func() {
cmd := exec.Command("kill", "-SIGINT", fmt.Sprint(os.Getpid()))
cmd.Stderr = os.Stderr
cmd.Stdout = os.Stdout
if err := cmd.Run(); err != nil {
panic(err)
}
}()
<-ctx.Done()
println("Exiting gracefully")
}
41 changes: 7 additions & 34 deletions examples/errgroup/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,60 +3,33 @@ package main
import (
"context"
"fmt"
"time"

"github.com/sergerad/relax"
"golang.org/x/sync/errgroup"
)

func main() {
// Instantiate the main context
mainCtx, cancel := relax.MainContext()

// Use the main context for errgroup
g, ctx := errgroup.WithContext(mainCtx)
// Instantiate the main context and error group
g, ctx := relax.NewErrorGroup(relax.Context())

// Launch goroutine that blocks on context
g.Go(func() error {
// Relax panics
defer relax.Recover(cancel)
// Block
select {
case <-ctx.Done():
fmt.Println("blocking routine done")
}
<-ctx.Done()
fmt.Println("blocking routine done")
return nil
})

// Launch goroutine that resembles a long running processor
g.Go(func() error {
return exampleProcessor(ctx)
})

// Wait for errgroup
if err := g.Wait(); err != nil {
fmt.Println("error from group", err)
fmt.Println(err)
}
fmt.Println("shutting down")

// Sleep to give all goroutines a chance to exit due to ctx.Done()
time.Sleep(1)
}

func exampleProcessor(ctx context.Context) error {
childCtx, cancel := context.WithCancel(ctx)
go func() {
defer relax.Recover(cancel)
panic(1)
}()
go func() {
select {
case <-childCtx.Done():
}
fmt.Println("processor goroutine done")
}()
select {
case <-childCtx.Done():
fmt.Println("processor done")
return childCtx.Err()
}
panic("processor failed")
}
41 changes: 0 additions & 41 deletions relax.go

This file was deleted.

Loading

0 comments on commit 1a17854

Please sign in to comment.