diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..d134ae2 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,19 @@ + // For format details, see https://aka.ms/devcontainer.json. +{ + "name": "Ubuntu", + // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + // Features to add to the dev container. More info: https://containers.dev/features. + "features": { + "ghcr.io/devcontainers-extra/features/mise:1": {}, + "ghcr.io/devcontainers/features/docker-in-docker:2": {} + }, + "postCreateCommand": "bash .devcontainer/post_create.sh", + "customizations": { + "vscode": { + "extensions": [ + "golang.go" + ] + } + } +} diff --git a/.devcontainer/post_create.sh b/.devcontainer/post_create.sh new file mode 100644 index 0000000..cf64f30 --- /dev/null +++ b/.devcontainer/post_create.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +# note that bash will read from ~/.profile or ~/.bash_profile if the latter exists +# ergo, you may want to check to see which is defined on your system and only append to the existing file +echo 'eval "$(mise activate bash --shims)"' >>~/.bash_profile # this sets up non-interactive sessions +echo 'eval "$(mise activate bash)"' >>~/.bashrc # this sets up interactive sessions + +mise trust . + +mise install + +mise exec -- go install -v golang.org/x/tools/gopls@latest +mise exec -- go install -v github.com/go-delve/delve/cmd/dlv@latest diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml deleted file mode 100644 index 1e34acb..0000000 --- a/.github/workflows/go.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Go - -on: - pull_request: - branches: - - main - - master - - release/v* - - test-workflow-main - -jobs: - lint: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v5 - - - name: Set up Go - uses: actions/setup-go@v6 - with: - go-version: "1.22.12" - - - name: Lint with golangci-lint - uses: golangci/golangci-lint-action@v8 - with: - version: v2.5.0 - only-new-issues: true - - test: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v5 - - - name: Set up Go - uses: actions/setup-go@v6 - with: - go-version: "1.22.12" - - - name: Test - run: go test -v -race -failfast ./... diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml new file mode 100644 index 0000000..14c92c5 --- /dev/null +++ b/.github/workflows/pr-check.yml @@ -0,0 +1,25 @@ +name: PR Check + +on: + pull_request: + branches: + - main + +jobs: + check: + name: Lint & Test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Install mise + uses: jdx/mise-action@v4 + + - name: Download Go dependencies + run: go mod download + + - name: Run check (lint + test) + run: make check diff --git a/.tool-versions b/.tool-versions deleted file mode 100644 index a0cffcc..0000000 --- a/.tool-versions +++ /dev/null @@ -1,2 +0,0 @@ -golang 1.22.12 -golangci-lint 2.5.0 diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..74473b0 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,27 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Launch test function", + "type": "go", + "request": "launch", + "mode": "test", + "program": "${workspaceFolder}", + "args": [ + "-test.run", + "MyTestFunction" + ] + }, + { + "name": "Launch Workspace", + "type": "go", + "request": "launch", + "mode": "debug", + "program": "${workspaceFolder}", + "cwd": "${workspaceFolder}", + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..5726fc4 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,17 @@ +{ + "go.useLanguageServer": true, + "go.toolsManagement.autoUpdate": true, + "go.lintTool": "golangci-lint-v2", + "go.lintFlags": [ + "--path-mode=abs", + "--fast-only" + ], + "go.alternateTools": { + "customFormatter": "golangci-lint-v2" + }, + "go.formatTool": "custom", + "go.formatFlags": [ + "fmt", + "--stdin" + ] +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2207c49 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,96 @@ +# AGENTS.md + +This file provides instructions and conventions for AI coding agents working in this repository. + +## Project Overview + +`github.com/min0625/errgroup` is a Go library that extends [`golang.org/x/sync/errgroup`](https://pkg.go.dev/golang.org/x/sync/errgroup) with panic recovery. Panics occurring in goroutines started by `Go` or `TryGo` are caught and re-panicked inside `Wait`, wrapped as `PanicError` or `PanicValue`. + +### Packages + +| Path | Module path | Description | +|------|-------------|-------------| +| `/` (root) | `github.com/min0625/errgroup` | Drop-in replacement for `golang.org/x/sync/errgroup` | +| `x/errgroup/` | `github.com/min0625/errgroup/x/errgroup` | Context-aware variant — passes `context.Context` into each goroutine function | + +## Repository Structure + +``` +errgroup.go # Core Group type and methods +panic.go # PanicError, PanicValue types and exception() helper +errgroup_test.go # Tests for the root package +example_test.go # Runnable examples for the root package +go.mod # Module definition +Makefile # Developer commands +mise.toml # Pinned tool versions (Go, golangci-lint) +x/errgroup/ + errgroup.go # Context-aware Group type + panic.go # Re-exports panic types from the root package (if any) + errgroup_test.go # Tests for the x/errgroup package + example_test.go # Runnable examples for the x/errgroup package +``` + +## Tool Versions + +Tool versions are pinned in `mise.toml`. Install them with: + +```sh +mise install +``` + +| Tool | Version | +|------|---------| +| Go | 1.24.x | +| golangci-lint | 2.x | + +## Common Commands + +All commands should be run from the repository root. + +| Command | Description | +|---------|-------------| +| `make fmt` | Format all Go source files via `golangci-lint fmt` | +| `make lint` | Run linter (`golangci-lint run`) | +| `make fix` | Run linter with auto-fix (`golangci-lint run --fix`) | +| `make test` | Run all tests with race detector (`go test -v -race -failfast ./...`) | +| `make check` | Run `lint` then `test` (full CI gate) | + +Always run `make check` before considering a change complete. + +## Development Guidelines + +### Code Style + +- Follow standard Go conventions (`gofmt`, `goimports`). +- Use `make fmt` to format code; do not manually reformat. +- Exported symbols must have GoDoc comments. Keep them concise and consistent with the existing style. +- Internal helpers (unexported) should be commented only when non-obvious. + +### Testing + +- All new behaviour must have corresponding tests. +- Tests must be parallel where possible (`t.Parallel()`). +- Use [`github.com/stretchr/testify`](https://pkg.go.dev/github.com/stretchr/testify) (`assert`, `require`) — consistent with the existing test suite. +- Run tests with the race detector: `make test` (uses `-race` flag). +- Place tests in `_test` package (external test package), e.g. `package errgroup_test`. + +### Panic Handling + +- `PanicError` wraps recovered values that implement `error`. +- `PanicValue` wraps all other recovered values. +- Both expose a `Stack []byte` field with the captured stack trace. +- When multiple goroutines panic concurrently, only the first panic is propagated; the rest are silently discarded. Preserve this invariant. + +### Dual-Package Consistency + +Changes to the root package (`errgroup.go`, `panic.go`) that affect the public API or panic-recovery behaviour must be reflected in `x/errgroup/` where applicable, and vice versa. + +### Dependencies + +- Keep dependencies minimal. The only non-test runtime dependency is `golang.org/x/sync`. +- Run `go mod tidy` after adding or removing imports. + +### Linting + +- The project uses `golangci-lint`. Lint rules are configured in the repository (check for `.golangci.yml` or inline `//nolint` directives). +- Suppress a lint warning with `//nolint: // ` only when genuinely necessary, not to silence legitimate issues. diff --git a/Makefile b/Makefile index 56338fb..717f29c 100644 --- a/Makefile +++ b/Makefile @@ -1,27 +1,13 @@ -MODULE_DIRS = . - -gowork: - go work init . - -tidy: - go mod tidy - -install-asdf: - -asdf install - -install: install-asdf tidy -# curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b $(shell go env GOPATH)/bin v2.5.0 - -fmt: install +fmt: golangci-lint fmt -v ./... -fix: install +fix: golangci-lint run -v --fix ./... -lint: install +lint: golangci-lint run -v ./... -test: install +test: go test -v -race -failfast ./... -check: fix lint test +check: lint test diff --git a/README.md b/README.md index 40d0cb4..a6ff80e 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,33 @@ # errgroup [![Go Reference](https://pkg.go.dev/badge/github.com/min0625/errgroup.svg)](https://pkg.go.dev/github.com/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 function. +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. Ref: https://github.com/golang/go/issues/53757 +## Packages + +| Package | Description | +|---|---| +| `github.com/min0625/errgroup` | Drop-in replacement for `golang.org/x/sync/errgroup` with panic recovery | +| [`github.com/min0625/errgroup/x/errgroup`](x/errgroup/README.md) | Context-aware variant — passes `context.Context` directly into each goroutine function | + ## Installation ```sh 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 @@ -42,6 +60,8 @@ func Example() { if err := g.Wait(); err != nil { // Handle error + fmt.Println(err) + return } // Output: oops diff --git a/errgroup.go b/errgroup.go index 4bf9932..9c7c408 100644 --- a/errgroup.go +++ b/errgroup.go @@ -15,6 +15,9 @@ import ( // // A zero Group is valid, has no limit on the number of active goroutines, // and does not cancel on error. +// +// Unhandled panics from goroutines started by Go or TryGo are caught and +// re-panicked in the Wait call, wrapped as PanicError or PanicValue. type Group struct { once sync.Once panicValue chan any @@ -51,6 +54,9 @@ func (g *Group) SetLimit(n int) { // // The first call to return a non-nil error cancels the group's context, if the // group was created by calling WithContext. The error will be returned by Wait. +// +// If f panics, the panic is caught and re-panicked in Wait, wrapped as a +// PanicError or PanicValue. func (g *Group) Go(f func() error) { g.eg.Go(g.do(f)) } @@ -59,12 +65,26 @@ func (g *Group) Go(f func() error) { // active goroutines in the group is currently below the configured limit. // // The return value reports whether the goroutine was started. +// +// If f panics, the panic is caught and re-panicked in Wait, wrapped as a +// PanicError or PanicValue. func (g *Group) TryGo(f func() error) bool { return g.eg.TryGo(g.do(f)) } // Wait blocks until all function calls from the Go method have returned, then // returns the first non-nil error (if any) from them. +// +// If any goroutine panicked, Wait re-panics with the recovered value wrapped +// as a PanicError (if the panic value implements error) or PanicValue. +// If multiple goroutines panic, only the first panic is re-panicked; the rest +// are silently discarded. +// +// Implementation note: Wait runs g.eg.Wait() in a separate goroutine so that +// a panic inside eg.Wait itself (e.g. from the underlying errgroup) can also be +// caught and forwarded through the same panicValue channel. The inner goroutine +// writes to a buffered channel of size 1, so it always completes and is never +// leaked, even when Wait returns early via a panic branch. func (g *Group) Wait() error { g.init() @@ -82,9 +102,13 @@ func (g *Group) Wait() error { select { case panicValue := <-g.panicValue: + // A goroutine panicked before eg.Wait returned. The inner goroutine + // will eventually write to waitError (buffered) and exit on its own. panic(panicValue) case err := <-waitError: - // Double check panic occurred + // eg.Wait returned normally; do a non-blocking check in case a panic + // was also sent (e.g. the goroutine panicked and the sentinel error + // arrived first). select { case panicValue := <-g.panicValue: panic(panicValue) diff --git a/errgroup_test.go b/errgroup_test.go index 1ace6a3..5f3aa3f 100644 --- a/errgroup_test.go +++ b/errgroup_test.go @@ -67,7 +67,7 @@ func Test_WithContext(t *testing.T) { }) require.ErrorIs(t, g.Wait(), myErr) - assert.True(t, jobIsCanceled, true) + assert.True(t, jobIsCanceled) } func Test_Group_Error(t *testing.T) { @@ -184,3 +184,64 @@ func Test_Group_PanicError(t *testing.T) { _ = g.Wait() } + +func Test_Group_TryGo(t *testing.T) { + t.Parallel() + + var g errgroup.Group + + g.SetLimit(1) + + started := make(chan struct{}) + release := make(chan struct{}) + + // First goroutine: occupies the single slot. + accepted := g.TryGo(func() error { + close(started) + <-release + + return nil + }) + + require.True(t, accepted, "first TryGo should be accepted") + + // Wait until the first goroutine is running so the limit is truly reached. + <-started + + // Second TryGo should be rejected because the limit is reached. + rejected := g.TryGo(func() error { + return nil + }) + + assert.False(t, rejected, "TryGo should be rejected when limit is reached") + + // Release the first goroutine. + close(release) + + require.NoError(t, g.Wait()) +} + +func Test_Group_TryGo_Panic(t *testing.T) { + t.Parallel() + + var g errgroup.Group + + accepted := g.TryGo(func() error { + panic("oops from TryGo") + }) + + require.True(t, accepted) + + defer func() { + if p := recover(); p != nil { + pv := p.(errgroup.PanicValue) + + assert.Equal(t, "oops from TryGo", pv.Recovered) + assert.Condition(t, func() (success bool) { + return len(pv.Stack) > 0 + }) + } + }() + + _ = g.Wait() +} diff --git a/go.mod b/go.mod index 5c897d3..7a3da7b 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,10 @@ module github.com/min0625/errgroup -go 1.22.12 +go 1.25.0 require ( github.com/stretchr/testify v1.9.0 - golang.org/x/sync v0.8.0 + golang.org/x/sync v0.20.0 ) require ( diff --git a/go.sum b/go.sum index c19eead..d7b9af9 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= -golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..88b0dbf --- /dev/null +++ b/mise.toml @@ -0,0 +1,3 @@ +[tools] +go = "1.25.9" +golangci-lint = "2.11.2" diff --git a/panic.go b/panic.go index f773ab8..7e683e0 100644 --- a/panic.go +++ b/panic.go @@ -10,8 +10,10 @@ import ( // A PanicError wraps an error recovered from an unhandled panic // when calling a function passed to Go or TryGo. type PanicError struct { + // Recovered is the error value that was passed to panic. Recovered error - Stack []byte + // Stack is the stack trace captured at the point of the panic. + Stack []byte } func (p PanicError) Error() string { @@ -26,8 +28,10 @@ func (p PanicError) Unwrap() error { return p.Recovered } // recovered from an unhandled panic when calling a function passed to Go or // TryGo. type PanicValue struct { + // Recovered is the value that was passed to panic. Recovered any - Stack []byte + // Stack is the stack trace captured at the point of the panic. + Stack []byte } func (p PanicValue) String() string { diff --git a/x/errgroup/README.md b/x/errgroup/README.md new file mode 100644 index 0000000..f80d5b4 --- /dev/null +++ b/x/errgroup/README.md @@ -0,0 +1,73 @@ +# 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) + +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 + +| | `github.com/min0625/errgroup` | `github.com/min0625/errgroup/x/errgroup` | +|---|---|---| +| Goroutine signature | `func() error` | `func(context.Context) error` | +| Context access | capture via closure | passed as argument | +| Constructor | `WithContext(ctx)` returns `(*Group, context.Context)` | `New(ctx)` returns `*Group` | +| Zero value | valid, no context cancellation | valid, uses `context.Background()` | + +## Installation + +```sh +go get github.com/min0625/errgroup/x/errgroup +``` + +## Example + +```go +func Example() { + // 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 + + defer func() { + // Will catch the 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 { + // Do something + return nil + }) + + g.Go(func(_ context.Context) error { + panic("oops") + }) + + if err := g.Wait(); err != nil { + // Handle error + fmt.Println(err) + return + } + + // 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. + +## Zero value caveat + +A zero-value `Group` (not created with `New`) is valid and automatically initialises itself on the first call to `Go`, `TryGo`, `SetLimit`, or `Wait`. However, the base context will be fixed to `context.Background()`. To use a custom cancellable context, always create the group with `New(ctx)`. diff --git a/x/errgroup/errgroup.go b/x/errgroup/errgroup.go index 5635d5a..c307f24 100644 --- a/x/errgroup/errgroup.go +++ b/x/errgroup/errgroup.go @@ -12,14 +12,19 @@ import ( // A Group is a collection of goroutines working on subtasks that are part of // the same overall task. // -// A zero Group is valid and has no limit on the number of active goroutines. +// A zero Group is valid, has no limit on the number of active goroutines, +// and uses context.Background() as its base context. To use a custom context +// that is canceled on error, create a Group with New instead. +// +// Unhandled panics from goroutines started by Go or TryGo are caught and +// re-panicked in the Wait call, wrapped as PanicError or PanicValue. type Group struct { once sync.Once eg *errgroup.Group ctx context.Context } -// New returns a new Group and an associated Context derived from ctx. +// New returns a new Group whose goroutines receive a Context derived from ctx. // // The derived Context is canceled the first time a function passed to Go // returns a non-nil error or the first time Wait returns, whichever occurs @@ -38,18 +43,25 @@ func New(ctx context.Context) *Group { // goroutine without exceeding the configured limit. // // The limit must not be modified while any goroutines in the group are active. +// +// Calling SetLimit on a zero-value Group (i.e. not created with New) initializes +// the group with context.Background() as its base context. func (g *Group) SetLimit(n int) { g.tryInit(context.Background()) g.eg.SetLimit(n) } -// Go calls the given function in a new goroutine. -// It blocks until the new goroutine can be added without the number of -// active goroutines in the group exceeding the configured limit. +// Go calls the given function in a new goroutine, passing the group's derived +// Context as the argument. It blocks until the new goroutine can be added +// without the number of active goroutines in the group exceeding the configured +// limit. // // The first call to return a non-nil error cancels the group's context, if the // group was created by calling New. The error will be returned by Wait. +// +// If f panics, the panic is caught and re-panicked in Wait, wrapped as a +// PanicError or PanicValue. func (g *Group) Go(f func(context.Context) error) { g.tryInit(context.Background()) @@ -58,10 +70,14 @@ func (g *Group) Go(f func(context.Context) error) { }) } -// TryGo calls the given function in a new goroutine only if the number of -// active goroutines in the group is currently below the configured limit. +// TryGo calls the given function in a new goroutine, passing the group's +// derived Context as the argument, only if the number of active goroutines in +// the group is currently below the configured limit. // // The return value reports whether the goroutine was started. +// +// If f panics, the panic is caught and re-panicked in Wait, wrapped as a +// PanicError or PanicValue. func (g *Group) TryGo(f func(context.Context) error) bool { g.tryInit(context.Background()) diff --git a/x/errgroup/errgroup_test.go b/x/errgroup/errgroup_test.go index b168838..495863e 100644 --- a/x/errgroup/errgroup_test.go +++ b/x/errgroup/errgroup_test.go @@ -67,7 +67,7 @@ func Test_WithContext(t *testing.T) { }) require.ErrorIs(t, g.Wait(), myErr) - assert.True(t, jobIsCanceled, true) + assert.True(t, jobIsCanceled) } func Test_WithContext_ZeroValue(t *testing.T) { @@ -95,7 +95,7 @@ func Test_WithContext_ZeroValue(t *testing.T) { }) require.ErrorIs(t, g.Wait(), myErr) - assert.True(t, jobIsCanceled, true) + assert.True(t, jobIsCanceled) } func Test_Group_Error(t *testing.T) { @@ -212,3 +212,93 @@ func Test_Group_PanicError(t *testing.T) { _ = g.Wait() } + +func Test_Group_TryGo(t *testing.T) { + t.Parallel() + + var g errgroup.Group + + g.SetLimit(1) + + started := make(chan struct{}) + release := make(chan struct{}) + + // First goroutine: occupies the single slot. + accepted := g.TryGo(func(_ context.Context) error { + close(started) + <-release + + return nil + }) + + require.True(t, accepted, "first TryGo should be accepted") + + // Wait until the first goroutine is running so the limit is truly reached. + <-started + + // Second TryGo should be rejected because the limit is reached. + rejected := g.TryGo(func(_ context.Context) error { + return nil + }) + + assert.False(t, rejected, "TryGo should be rejected when limit is reached") + + // Release the first goroutine. + close(release) + + require.NoError(t, g.Wait()) +} + +func Test_Group_TryGo_ContextPropagation(t *testing.T) { + t.Parallel() + + myErr := errors.New("oops") + + var jobIsCanceled bool + + g := errgroup.New(context.Background()) + + g.TryGo(func(_ context.Context) error { + return myErr + }) + + accepted := g.TryGo(func(ctx context.Context) error { + select { + case <-ctx.Done(): + jobIsCanceled = true + case <-time.After(3 * time.Second): + assert.Fail(t, "context should be canceled") + } + + return nil + }) + + require.True(t, accepted) + require.ErrorIs(t, g.Wait(), myErr) + assert.True(t, jobIsCanceled) +} + +func Test_Group_TryGo_Panic(t *testing.T) { + t.Parallel() + + var g errgroup.Group + + accepted := g.TryGo(func(_ context.Context) error { + panic("oops from TryGo") + }) + + require.True(t, accepted) + + defer func() { + if p := recover(); p != nil { + pv := p.(errgroup.PanicValue) + + assert.Equal(t, "oops from TryGo", pv.Recovered) + assert.Condition(t, func() (success bool) { + return len(pv.Stack) > 0 + }) + } + }() + + _ = g.Wait() +}