diff --git a/.golangci.yaml b/.golangci.yaml index 16a0424..e6a233c 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -53,7 +53,6 @@ linters: - fatcontext - gocheckcompilerdirectives - gocognit - - goconst - gocritic - gomodguard_v2 - goprintffuncname diff --git a/README.md b/README.md index 539ff19..d4b722f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,9 @@ [![Go Reference](https://pkg.go.dev/badge/github.com/min0625/errgroup.svg)](https://pkg.go.dev/github.com/min0625/errgroup) [![codecov](https://codecov.io/gh/min0625/errgroup/branch/main/graph/badge.svg)](https://codecov.io/gh/min0625/errgroup) -A recoverable errgroup based on `golang.org/x/sync/errgroup` that can recover from panics. Panics are caught and re-panicked in the `Wait` call. +**English** | [繁體中文](README.zh-TW.md) + +A drop-in `golang.org/x/sync/errgroup` replacement that recovers panics in goroutines and re-panics them inside the `Wait` call. Ref: https://github.com/golang/go/issues/53757 @@ -18,21 +20,17 @@ Ref: https://github.com/golang/go/issues/53757 go get github.com/min0625/errgroup ``` -## Panic behaviour - -Panics in goroutines started by `Go` or `TryGo` are caught and re-panicked inside `Wait`, wrapped as: - -- `PanicError` — when the panicked value implements `error` -- `PanicValue` — for all other values - -Both types expose a `Stack` field containing the stack trace captured at the point of the panic. - -If multiple goroutines panic concurrently, only the first panic is propagated; the rest are silently discarded. - ## Example ```go +package main + +import ( + "fmt" -func Example() { + "github.com/min0625/errgroup" +) + +func main() { // This case uses "github.com/min0625/errgroup" which will catch panics. // If you import "golang.org/x/sync/errgroup" instead, it won't catch panics. // You can try this in the Go Playground: https://go.dev/play/p/7pUX6uQ2mCH @@ -67,5 +65,15 @@ func Example() { // Output: oops } - ``` + +## Panic behaviour + +Panics in goroutines started by `Go` or `TryGo` are caught and re-panicked inside `Wait`, wrapped as: + +- `PanicError` — when the panicked value implements `error` +- `PanicValue` — for all other values + +Both types expose a `Stack` field containing the stack trace captured at the point of the panic. + +If multiple goroutines panic concurrently, only the first panic is propagated; the rest are silently discarded. diff --git a/README.zh-TW.md b/README.zh-TW.md new file mode 100644 index 0000000..cd21f02 --- /dev/null +++ b/README.zh-TW.md @@ -0,0 +1,79 @@ +# errgroup +[![Go Reference](https://pkg.go.dev/badge/github.com/min0625/errgroup.svg)](https://pkg.go.dev/github.com/min0625/errgroup) +[![codecov](https://codecov.io/gh/min0625/errgroup/branch/main/graph/badge.svg)](https://codecov.io/gh/min0625/errgroup) + +[English](README.md) | **繁體中文** + +可直接替換 `golang.org/x/sync/errgroup` 的套件,會回收 goroutine 中的 panic,並在 `Wait` 呼叫中重新拋出。 + +參考:https://github.com/golang/go/issues/53757 + +## 套件 + +| 套件 | 說明 | +|---|---| +| `github.com/min0625/errgroup` | `golang.org/x/sync/errgroup` 的直接替換版,額外提供 panic 回收 | +| [`github.com/min0625/errgroup/x/errgroup`](x/errgroup/README.zh-TW.md) | 情境感知(context-aware)變體 — 直接將 `context.Context` 傳入每個 goroutine 函式 | + +## 安裝 +```sh +go get github.com/min0625/errgroup +``` + +## 範例 +```go +package main + +import ( + "fmt" + + "github.com/min0625/errgroup" +) + +func main() { + // 此範例使用 "github.com/min0625/errgroup",它會捕捉 panic。 + // 若改為匯入 "golang.org/x/sync/errgroup",則不會捕捉 panic。 + // 你可以在 Go Playground 試玩:https://go.dev/play/p/7pUX6uQ2mCH + var g errgroup.Group + + defer func() { + // 會捕捉到 panic。 + if p := recover(); p != nil { + switch t := p.(type) { + case errgroup.PanicValue: + fmt.Println(t.Recovered) + case errgroup.PanicError: + fmt.Println(t.Recovered) + } + } + }() + + g.Go(func() error { + // 做些事情 + return nil + }) + + g.Go(func() error { + panic("oops") + }) + + if err := g.Wait(); err != nil { + // 處理錯誤 + fmt.Println(err) + return + } + + // Output: oops +} +``` + +## Panic 行為 + +由 `Go` 或 `TryGo` 啟動的 goroutine 若發生 panic,會被捕捉並在 `Wait` 中重新拋出,並包裝為: + +- `PanicError` — 當 panic 的值實作了 `error` 介面時 +- `PanicValue` — 其他所有情況 + +兩種型別都會提供 `Stack` 欄位,內含 panic 發生當下所擷取的堆疊追蹤(stack trace)。 + +若有多個 goroutine 同時 panic,只有第一個 panic 會被傳播,其餘會被靜默丟棄。 diff --git a/errgroup_test.go b/errgroup_test.go index 5f3aa3f..6425571 100644 --- a/errgroup_test.go +++ b/errgroup_test.go @@ -221,6 +221,107 @@ func Test_Group_TryGo(t *testing.T) { require.NoError(t, g.Wait()) } +func Test_Group_PanicError_Unwrap(t *testing.T) { + t.Parallel() + + sentinel := errors.New("oops") + + var g errgroup.Group + + g.Go(func() error { + panic(sentinel) + }) + + defer func() { + p := recover() + require.NotNil(t, p) + + err, ok := p.(error) + require.True(t, ok) + + // PanicError.Unwrap exposes the original error to errors.Is/As. + require.ErrorIs(t, err, sentinel) + + var pe errgroup.PanicError + require.ErrorAs(t, err, &pe) + assert.Equal(t, sentinel, pe.Recovered) + }() + + _ = g.Wait() +} + +func Test_Group_Panic_AlreadyPanicError(t *testing.T) { + t.Parallel() + + orig := errgroup.PanicError{ + Recovered: errors.New("oops"), + Stack: []byte("original stack"), + } + + var g errgroup.Group + + g.Go(func() error { + panic(orig) + }) + + defer func() { + p := recover() + require.NotNil(t, p) + + // An already-wrapped PanicError is propagated unchanged, not + // re-wrapped (the original Stack is preserved). + pe, ok := p.(errgroup.PanicError) + require.True(t, ok) + assert.Equal(t, orig, pe) + }() + + _ = g.Wait() +} + +func Test_Group_Panic_AlreadyPanicValue(t *testing.T) { + t.Parallel() + + orig := errgroup.PanicValue{ + Recovered: "oops", + Stack: []byte("original stack"), + } + + var g errgroup.Group + + g.Go(func() error { + panic(orig) + }) + + defer func() { + p := recover() + require.NotNil(t, p) + + // An already-wrapped PanicValue is propagated unchanged, not + // re-wrapped (the original Stack is preserved). + pv, ok := p.(errgroup.PanicValue) + require.True(t, ok) + assert.Equal(t, orig, pv) + }() + + _ = g.Wait() +} + +func Test_PanicValue_String_WithoutStack(t *testing.T) { + t.Parallel() + + pv := errgroup.PanicValue{Recovered: "oops"} + + assert.Equal(t, "recovered from errgroup.Group: oops", pv.String()) +} + +func Test_PanicValue_String_WithStack(t *testing.T) { + t.Parallel() + + pv := errgroup.PanicValue{Recovered: "oops", Stack: []byte("the stack")} + + assert.Equal(t, "recovered from errgroup.Group: oops\nthe stack", pv.String()) +} + func Test_Group_TryGo_Panic(t *testing.T) { t.Parallel() diff --git a/x/errgroup/README.md b/x/errgroup/README.md index b039b58..36e703e 100644 --- a/x/errgroup/README.md +++ b/x/errgroup/README.md @@ -2,6 +2,8 @@ [![Go Reference](https://pkg.go.dev/badge/github.com/min0625/errgroup/x/errgroup.svg)](https://pkg.go.dev/github.com/min0625/errgroup/x/errgroup) [![codecov](https://codecov.io/gh/min0625/errgroup/branch/main/graph/badge.svg)](https://codecov.io/gh/min0625/errgroup) +**English** | [繁體中文](README.zh-TW.md) + A context-aware variant of [`github.com/min0625/errgroup`](../../README.md) that passes a derived `context.Context` directly into each goroutine function, eliminating the need to capture the context via closure. ## Differences from the root package @@ -22,7 +24,16 @@ go get github.com/min0625/errgroup/x/errgroup ## Example ```go -func Example() { +package main + +import ( + "context" + "fmt" + + "github.com/min0625/errgroup/x/errgroup" +) + +func main() { // This case uses "github.com/min0625/errgroup/x/errgroup" which will catch panics. // If you import "golang.org/x/sync/errgroup" instead, it won't catch panics. var g errgroup.Group diff --git a/x/errgroup/README.zh-TW.md b/x/errgroup/README.zh-TW.md new file mode 100644 index 0000000..0a388fa --- /dev/null +++ b/x/errgroup/README.zh-TW.md @@ -0,0 +1,85 @@ +# errgroup/x/errgroup +[![Go Reference](https://pkg.go.dev/badge/github.com/min0625/errgroup/x/errgroup.svg)](https://pkg.go.dev/github.com/min0625/errgroup/x/errgroup) +[![codecov](https://codecov.io/gh/min0625/errgroup/branch/main/graph/badge.svg)](https://codecov.io/gh/min0625/errgroup) + +[English](README.md) | **繁體中文** + +[`github.com/min0625/errgroup`](../../README.zh-TW.md) 的情境感知(context-aware)變體,會將衍生的 `context.Context` 直接傳入每個 goroutine 函式,免去透過閉包(closure)擷取 context 的需要。 + +## 與根套件的差異 + +| | `github.com/min0625/errgroup` | `github.com/min0625/errgroup/x/errgroup` | +|---|---|---| +| Goroutine 函式簽章 | `func() error` | `func(context.Context) error` | +| 取得 context | 透過閉包擷取 | 以參數傳入 | +| 建構子 | `WithContext(ctx)` 回傳 `(*Group, context.Context)` | `New(ctx)` 回傳 `*Group` | +| 零值 | 有效,無 context 取消機制 | 有效,使用 `context.Background()` | + +## 安裝 + +```sh +go get github.com/min0625/errgroup/x/errgroup +``` + +## 範例 + +```go +package main + +import ( + "context" + "fmt" + + "github.com/min0625/errgroup/x/errgroup" +) + +func main() { + // 此範例使用 "github.com/min0625/errgroup/x/errgroup",它會捕捉 panic。 + // 若改為匯入 "golang.org/x/sync/errgroup",則不會捕捉 panic。 + var g errgroup.Group + + defer func() { + // 會捕捉到 panic。 + if p := recover(); p != nil { + switch t := p.(type) { + case errgroup.PanicValue: + fmt.Println(t.Recovered) + case errgroup.PanicError: + fmt.Println(t.Recovered) + } + } + }() + + g.Go(func(_ context.Context) error { + // 做些事情 + return nil + }) + + g.Go(func(_ context.Context) error { + panic("oops") + }) + + if err := g.Wait(); err != nil { + // 處理錯誤 + fmt.Println(err) + return + } + + // Output: oops +} +``` + +## Panic 行為 + +由 `Go` 或 `TryGo` 啟動的 goroutine 若發生 panic,會被捕捉並在 `Wait` 中重新拋出,並包裝為: + +- `PanicError` — 當 panic 的值實作了 `error` 介面時 +- `PanicValue` — 其他所有情況 + +兩種型別都會提供 `Stack` 欄位,內含 panic 發生當下所擷取的堆疊追蹤(stack trace)。 + +若有多個 goroutine 同時 panic,只有第一個 panic 會被傳播,其餘會被靜默丟棄。 + +## 零值注意事項 + +零值的 `Group`(未透過 `New` 建立)是有效的,並會在第一次呼叫 `Go`、`TryGo`、`SetLimit` 或 `Wait` 時自動初始化。但其基礎 context 會固定為 `context.Background()`。若要使用自訂的可取消 context,請務必以 `New(ctx)` 建立群組。