From b45c4108d560418d3ce8d008406facf7013f8fe0 Mon Sep 17 00:00:00 2001 From: Alexander Menshchikov Date: Thu, 29 Oct 2026 18:55:07 +0300 Subject: [PATCH 01/20] Add closer README and document the package Describe the global and the scoped closer, the close context semantics and the error handling. Add doc comments to the exported API and to the non-obvious parts of the implementation. --- closer/README.md | 172 ++++++++++++++++++++++++++++++++++++++++++++++ closer/closer.go | 30 +++++++- closer/options.go | 1 + 3 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 closer/README.md diff --git a/closer/README.md b/closer/README.md new file mode 100644 index 0000000..765818c --- /dev/null +++ b/closer/README.md @@ -0,0 +1,172 @@ +# closer + +A small, dependency-free Go package for collecting cleanup functions and running +them exactly once — either explicitly, or automatically when a context is canceled +or an OS signal arrives. + +The package has two layers: + +- a set of package-level functions operating on a global closer, useful for + "process-wide" cleanup; +- the [`Closer`](./closer.go) interface and its implementation, useful when the + cleanup scope is explicit and must be bound to a particular context. + +## Public API + +### Types + +```go +type CloseFn func(ctx context.Context) error + +type Closer interface { + Adder + // SetContext sets context which will be passed into the close functions without cancel. + SetContext(ctx context.Context) + // Done returns signal channel. + Done() <-chan struct{} + // Err returns a joint error based on close functions errors. + Err() error + // CloseAll runs close functions in arbitrary order. + CloseAll() +} + +type Adder interface { + // Add adds close function. + Add(CloseFn) +} +``` + +- `CloseFn` is a cleanup function. It receives a context, but not the one the + closer was bound to — the cancellation is stripped away (see + [The close context](#the-close-context)). +- `Adder` is the registration half of `Closer`: it only knows how to register + a close function. +- `Closer` adds the lifecycle to it: setting the close context, waiting for the + cleanup to finish (`Done`), reading the result (`Err`) and triggering it + (`CloseAll`). + +### The global closer + +```go +func SetContext(ctx context.Context) +func Add(f CloseFn) +func Done() <-chan struct{} +func Err() error +func CloseAll() +``` + +These operate on a package-level closer that is created on initialization. They +are handy for libraries that need to register a cleanup without owning the +application's lifecycle: the application calls `closer.CloseAll()` on shutdown +and inspects `closer.Err()` afterwards. + +The global closer is created with `context.Background()`, so +`closer.SetContext(...)` is the way to give the close functions access to the +application context. + +### Creating a closer + +```go +func New(ctx context.Context, opts ...Option) (context.Context, *closer) +``` + +`New` returns the closer together with the context it is bound to. The context +is returned because it may be replaced by `New` itself: with `WithSignals` the +returned context is a signal-notifiable one, and it is this returned value — +not the one passed in — that has to be used by the application. + +```go +ctx, cancel := context.WithCancel(context.Background()) +defer cancel() + +ctx, cl := closer.New(ctx, closer.WithContextCancel()) +defer cl.CloseAll() +``` + +`New` panics if `ctx` is nil. + +## Options + +```go +func WithContextCancel() Option +func WithSignals(signals ...os.Signal) Option +``` + +- `WithContextCancel` closes the closer automatically as soon as the context + passed to `New` is done. +- `WithSignals` does the same, but the context is first wrapped into a + signal-notifiable one, so that the closer is closed when any of the given + signals arrives. It implies `WithContextCancel`. + +```go +ctx, cl := closer.New(context.Background(), closer.WithSignals(syscall.SIGINT, syscall.SIGTERM)) + +cl.Add(func(ctx context.Context) error { + fmt.Println("shutting down") + return nil +}) + +<-ctx.Done() // the context is canceled on SIGINT/SIGTERM +<-cl.Done() // ... and the closer is closed right after that +``` + +## Closing + +`CloseAll` runs every registered close function and then closes the `Done` +channel. It is idempotent: only the first call has an effect, subsequent calls +return immediately. + +The close functions run **concurrently**, in an arbitrary order, and each of them +gets its own goroutine. There is no ordering guarantee between them, so a close +function must not depend on another one having finished. + +`Done` is closed after all close functions have returned, which makes it the +right place to wait for the cleanup to complete: + +```go +cl.CloseAll() +<-cl.Done() + +if err := cl.Err(); err != nil { + // at least one close function failed +} +``` + +## Errors + +`Err` returns the `errors.Join` of all errors returned by the close functions. +It returns `nil` while the closer is not closed yet, and `nil` after it has been +closed if every close function succeeded. + +Errors are collected, not short-circuited: a failing close function does not +prevent the others from running. + +## The close context + +Close functions receive a context that carries the **values** of the context +set on the closer, but is **not** canceled when that context is canceled +(the implementation uses `context.WithoutCancel`). + +This is what makes the automatic shutdown work: with `WithContextCancel` the +closer is closed exactly because the context is done, and passing that canceled +context to the cleanup functions would make them abort immediately. + +The context can be replaced at any time with `SetContext`: + +```go +cl.SetContext(context.WithValue(ctx, key, "value")) +``` + +The closer reads the context at the moment it is closed, so the last value set +before `CloseAll` is the one the close functions receive. + +## Concurrency + +`Add`, `SetContext`, `Done`, `Err` and `CloseAll` are safe for concurrent use. + +`Add` is lock-free: the functions are stored in an atomically swapped slice, so +registering a cleanup never blocks the caller. Registering a function after the +closer has been closed has no effect — the function is never called. + +`CloseAll` runs the close functions concurrently and waits for all of them +before closing the `Done` channel. diff --git a/closer/closer.go b/closer/closer.go index 0c57c8c..ab610fa 100644 --- a/closer/closer.go +++ b/closer/closer.go @@ -10,6 +10,11 @@ import ( "sync/atomic" ) +// CloseFn is a cleanup function registered in a Closer. +// +// The context passed to it carries the values of the context set on the +// Closer, but is detached from its cancellation, so that the cleanup is able +// to finish its work even after the context has been canceled. type CloseFn func(ctx context.Context) error // Closer cleans up resources. @@ -39,6 +44,7 @@ type closer struct { once sync.Once } +// global is the Closer used by the package-level functions. var _, global = New(context.Background()) // SetContext sets context into the global Closer. @@ -66,7 +72,14 @@ func CloseAll() { global.CloseAll() } -// New returns Closer implementation. +// New returns a Closer implementation together with the context it is bound to. +// +// The returned context is the one passed in, wrapped into a signal-notifiable +// context when [WithSignals] is used. When [WithContextCancel] or +// [WithSignals] is used, the closer is closed automatically as soon as that +// context is done. +// +// New panics if ctx is nil. func New(ctx context.Context, opts ...Option) (context.Context, *closer) { o := options{} for _, opt := range opts { @@ -98,6 +111,7 @@ func New(ctx context.Context, opts ...Option) (context.Context, *closer) { return ctx, c } +// SetContext sets the context passed to the close functions. func (c *closer) SetContext(ctx context.Context) { if ctx == nil { panic("cannot set nil context") @@ -105,6 +119,10 @@ func (c *closer) SetContext(ctx context.Context) { c.ctx.Store(&ctx) } +// Add registers a close function. +// +// The function is called exactly once, when the closer is closed. Registering +// a function in an already closed closer has no effect. func (c *closer) Add(f CloseFn) { var funcs []CloseFn old := c.funcs.Load() @@ -126,10 +144,14 @@ func (c *closer) Add(f CloseFn) { } } +// Done returns a channel that is closed after all close functions have finished. func (c *closer) Done() <-chan struct{} { return c.done } +// Err returns the joined error of the close functions. +// +// It returns nil until the closer is closed. func (c *closer) Err() error { select { case <-c.done: @@ -140,6 +162,12 @@ func (c *closer) Err() error { } } +// CloseAll runs all registered close functions concurrently and closes the done +// channel. +// +// It is safe to call it multiple times: only the first call has an effect. The +// close functions receive a context detached from the cancellation of the +// context set on the closer, so that they are able to complete their work. func (c *closer) CloseAll() { //nolint:gocognit c.once.Do(func() { defer close(c.done) diff --git a/closer/options.go b/closer/options.go index e9cf3b1..9a31187 100644 --- a/closer/options.go +++ b/closer/options.go @@ -11,6 +11,7 @@ type options struct { ctxCancel bool } +// Option configures a Closer created by [New]. type Option func(options) options // WithContextCancel allows to call closer CloseAll on context cancel implicitly. From d5497309c6f2ef5a32b9c019d220da65b9b1ab7e Mon Sep 17 00:00:00 2001 From: Alexander Menshchikov Date: Thu, 29 Oct 2026 18:55:08 +0300 Subject: [PATCH 02/20] Add ctxkey README and document the package Describe how the unique context keys are created, how they behave and what advantages they give over a hand-written key type. Add doc comments to the exported API. --- ctxkey/README.md | 101 +++++++++++++++++++++++++++++++++++++++++++++++ ctxkey/ctxkey.go | 11 ++++++ 2 files changed, 112 insertions(+) create mode 100644 ctxkey/README.md diff --git a/ctxkey/README.md b/ctxkey/README.md new file mode 100644 index 0000000..eea3cf5 --- /dev/null +++ b/ctxkey/README.md @@ -0,0 +1,101 @@ +# ctxkey + +A tiny, dependency-free Go package for creating unique context keys. + +`context.WithValue` accepts any comparable value as a key, but the [official +advice](https://go.dev/blog/context#Customizing-Context) is to define a dedicated +type for every key, so that keys from different packages cannot collide. This +package does that in one line, without the boilerplate of declaring a type per +key. + +## Public API + +```go +type Key interface { + fmt.Stringer +} + +func New(name string) Key +``` + +- `New` returns a new key with the given name. +- `Key` is an opaque interface: the only thing a caller can do with a key is + compare it with another key or read its string representation. + +## Usage + +```go +import ( + "context" + + "github.com/nbgrp/pkg/ctxkey" +) + +var userID = ctxkey.New("user_id") + +func WithUserID(ctx context.Context, id int64) context.Context { + return context.WithValue(ctx, userID, id) +} + +func UserID(ctx context.Context) (int64, bool) { + id, ok := ctx.Value(userID).(int64) + return id, ok +} +``` + +The key is usually declared once, next to the accessor that uses it, and is then +passed around as an ordinary value. + +## Uniqueness + +Every call to `New` returns a distinct key. Two keys are equal only if they are +the same pointer, so a key created by `New("id")` in one package never matches +a key created by `New("id")` in another one — and it never matches a string +`"id"` either. + +```go +a := ctxkey.New("id") +b := ctxkey.New("id") + +fmt.Println(a == b) // false +ctx := context.WithValue(context.Background(), a, 1) +fmt.Println(ctx.Value(b)) // nil +fmt.Println(ctx.Value("id")) // nil +``` + +The name is only used for the string representation, so it does not have to be +unique. + +## String representation + +A key prints itself as `ctxkey ""`, which makes it recognizable in logs +and in `%+v` formatting of wrapped errors. + +```go +key := ctxkey.New("user_id") +fmt.Println(key) // ctxkey "user_id" +``` + +The `Key` interface embeds `fmt.Stringer`, so a key can be passed to any +formatting function directly. + +## Allocations + +A key is backed by a pointer to a private struct, so it fits into an `any` +without an extra allocation for the interface conversion: + +```go +var myKey = ctxkey.New("MyKey") +// 2 allocations: the context struct and the value +context.WithValue(ctx, myKey, someValue) +``` + +The common alternative — declaring a named integer type for the key — allocates +one extra time, because the key itself has to be boxed into an `any`: + +```go +type key int +var myKey key = 0 +// 3 allocations: the context struct, the key and the value +context.WithValue(ctx, myKey, someValue) +``` diff --git a/ctxkey/ctxkey.go b/ctxkey/ctxkey.go index 085caea..57e24bd 100644 --- a/ctxkey/ctxkey.go +++ b/ctxkey/ctxkey.go @@ -34,18 +34,29 @@ import ( "fmt" ) +// Key is a context key created by [New]. +// +// It is an opaque, unique value: two keys created by [New] are never equal, +// even if they were given the same name. type Key interface { fmt.Stringer } +// key is a pointer-backed context key. The pointer identity is what makes the +// key unique, the name is only used for the [fmt.Stringer] representation. type key struct { name string } +// New returns a new unique context key with the given name. +// +// The name is only used to make the key human-readable in logs and debug +// output; it does not affect the uniqueness of the key. func New(name string) Key { return &key{name} } +// String returns the human-readable representation of the key. func (k key) String() string { return `ctxkey "` + k.name + `"` } From 7d61df8a17e10d9211700d6245f5b3469ad2c966 Mon Sep 17 00:00:00 2001 From: Alexander Menshchikov Date: Thu, 29 Oct 2026 18:55:08 +0300 Subject: [PATCH 03/20] Add deferred README and document the package Describe how the close error is joined with the deferred one and when it is discarded. Add a package doc comment. --- deferred/README.md | 69 ++++++++++++++++++++++++++++++++++++++++++++ deferred/deferred.go | 2 ++ 2 files changed, 71 insertions(+) create mode 100644 deferred/README.md diff --git a/deferred/README.md b/deferred/README.md new file mode 100644 index 0000000..03f54f8 --- /dev/null +++ b/deferred/README.md @@ -0,0 +1,69 @@ +# deferred + +A tiny, dependency-free Go package with a single helper for closing resources +in a deferred call without losing the close error. + +## Public API + +```go +func Close(err *error, closer io.Closer) +``` + +`Close` closes the `closer` and joins the error it returns with the error +pointed to by `err`. + +It is meant to be used with `defer`, right after the resource is opened: + +```go +func process(path string) (err error) { + f, err := os.Open(path) + if err != nil { + return err + } + defer deferred.Close(&err, f) + + return doSomething(f) +} +``` + +Without the helper the close error would have to be handled explicitly, and the +usual `defer f.Close()` swallows it: + +```go +defer f.Close() // the error is dropped +``` + +## Semantics + +- The closer is **always** closed, even if `err` is nil. +- If `err` is nil, the close error is discarded — the closer is still closed, + but the result is thrown away. +- Otherwise the close error is joined with the current value of `*err` using + `errors.Join`, so both errors are reported. + +```go +deferred.Close(&err, closer) +// err == nil -> err stays nil, the close error is discarded +// err == errInternal -> err becomes the join of errInternal and the close error +``` + +Since the error is joined, the result is never equal to one of its parts. Use +`errors.Is` to check for a particular error: + +```go +defer deferred.Close(&err, closer) +// ... +if errors.Is(err, os.ErrNotExist) { + // ... +} +``` + +## Ordering + +`defer` runs the deferred calls in reverse order, so the resources are closed in +the opposite order to the one in which they were opened. This is the behavior +you usually want, and the helper does not change it. + +The helper is also safe to use with a closer that returns an error and a +function that returns one: both errors end up in the same joined error, and +neither of them is lost. diff --git a/deferred/deferred.go b/deferred/deferred.go index 5cd8cae..0e6aa30 100644 --- a/deferred/deferred.go +++ b/deferred/deferred.go @@ -1,5 +1,7 @@ // SPDX-License-Identifier: BSD-3-Clause +// Package deferred provides a helper to close a resource in a deferred call +// without losing the close error. package deferred import ( From 9772f852d9c8174355a3b3e0b1dcc247e52dd8c0 Mon Sep 17 00:00:00 2001 From: Alexander Menshchikov Date: Thu, 29 Oct 2026 18:55:08 +0300 Subject: [PATCH 04/20] Add protomap README and document the package Describe how the prototypes are built and used, what errors are returned and when the message is partially filled. --- protomap/README.md | 130 +++++++++++++++++++++++++++++++++++++++++++ protomap/protomap.go | 6 +- 2 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 protomap/README.md diff --git a/protomap/README.md b/protomap/README.md new file mode 100644 index 0000000..efe82d1 --- /dev/null +++ b/protomap/README.md @@ -0,0 +1,130 @@ +# protomap + +A small Go package for deserializing protobuf messages dynamically — by their +full descriptor name, without knowing their concrete types at compile time. + +It is useful when the message type is not known statically: a plugin registry, a +configuration format, a proxy that relays messages of arbitrary types, or a test +harness that decodes payloads produced elsewhere. + +## Public API + +```go +var ErrNoPrototype = errors.New("no prototype for message") + +type Protomap[T proto.Message] map[protoreflect.FullName]func() T + +func From[T proto.Message](messages []T) Protomap[T] +func (p Protomap[T]) Unmarshal(fullName protoreflect.FullName, data []byte) (T, error) +``` + +- `Protomap` maps the full name of a protobuf message descriptor + (`protoreflect.FullName`) to a constructor of new instances of that message. +- `From` builds a `Protomap` from a slice of messages, using each message only + as a prototype: the registered constructor always returns a **new, empty** + instance of the same type. +- `Unmarshal` creates a new instance of the registered type and decodes the + given bytes into it. + +## Usage + +```go +package main + +import ( + "fmt" + + "github.com/nbgrp/pkg/protomap" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/structpb" + "google.golang.org/protobuf/types/known/timestamppb" +) + +func main() { + p := protomap.From([]proto.Message{ + ×tamppb.Timestamp{}, + &structpb.Struct{}, + }) + + data, err := proto.Marshal(×tamppb.Timestamp{Seconds: 42}) + if err != nil { + panic(err) + } + + msg, err := p.Unmarshal("google.protobuf.Timestamp", data) + if err != nil { + panic(err) + } + + fmt.Println(msg.(*timestamppb.Timestamp).GetSeconds()) // 42 +} +``` + +`From` is normally called once, on startup, with the list of the message types +the application is able to handle. `Unmarshal` is then called for every incoming +payload, with the name of the message it is expected to contain. + +## Prototypes + +A prototype is used for two things: to obtain the full name of the message +descriptor, and to know the concrete Go type to instantiate. The prototype +itself is never returned to the caller — `Unmarshal` always creates a fresh +instance, so the decoded messages never share state. + +```go +src := ×tamppb.Timestamp{Seconds: 42} +p := protomap.From([]proto.Message{src}) + +first, _ := p.Unmarshal("google.protobuf.Timestamp", data) +second, _ := p.Unmarshal("google.protobuf.Timestamp", data) + +first.Seconds = 100 +fmt.Println(second.GetSeconds()) // 42 +``` + +## Building the map + +`From` accepts messages of different types in the same slice, as long as they +all implement the type parameter `T`. If the same message type occurs several +times, only a single prototype is registered for it. + +The map is usually built with `T = proto.Message`, which gives access to every +registered message type. A narrower type parameter gives a map that only +contains the messages of that type: + +```go +p := protomap.From([]*timestamppb.Timestamp{ + ×tamppb.Timestamp{}, +}) + +msg, err := p.Unmarshal("google.protobuf.Timestamp", data) +// msg is a *timestamppb.Timestamp, no type assertion needed +``` + +`From` panics if the slice contains a nil message or a message that is not used +as a pointer — a prototype that cannot be instantiated is a programming error, +and it is better to fail on startup than on the first incoming payload. The +prototype is also created eagerly for the same reason: a broken prototype is +reported when the map is built, not later, when a message is unmarshaled. + +## Errors + +`Unmarshal` returns an error wrapping `ErrNoPrototype` when the map has no +prototype for the requested name: + +```go +_, err := p.Unmarshal("google.protobuf.Unknown", data) +if errors.Is(err, protomap.ErrNoPrototype) { + // the message type is not registered +} +``` + +A decoding error is returned as is, wrapped with the name of the message for +context. In that case the message is returned too — it may be partially filled +with the data that was successfully parsed. + +## Concurrency + +`Protomap` is a plain map, so it must not be modified concurrently with +`Unmarshal`. Build it once, before it is used, and it is safe to read from +multiple goroutines. diff --git a/protomap/protomap.go b/protomap/protomap.go index 34b2714..1c6394a 100644 --- a/protomap/protomap.go +++ b/protomap/protomap.go @@ -77,7 +77,11 @@ func From[T proto.Message](messages []T) Protomap[T] { // registered under the given full descriptor name. // // It returns the new message instance and an error, if any. If the map has no -// prototype for the given name, the returned error wraps [ErrNoPrototype]. +// prototype for the given name, the returned error wraps [ErrNoPrototype] and +// the message is the zero value. +// +// On a decoding error the message is returned as well: it may be partially +// filled with the data that was successfully parsed. func (p Protomap[T]) Unmarshal(fullName protoreflect.FullName, data []byte) (msg T, err error) { protoFunc, ok := p[fullName] if !ok { From 7e7974a556ac9517f26f35956a4efd77e043c5c2 Mon Sep 17 00:00:00 2001 From: Alexander Menshchikov Date: Thu, 29 Oct 2026 18:55:08 +0300 Subject: [PATCH 05/20] Add strcase README and document the package Describe the detection rules, the conversion table and the abbreviation handling. Add doc comments to the exported API and to the conversion helpers. --- strcase/README.md | 121 +++++++++++++++++++++++++++++++++++++++++++++ strcase/strcase.go | 43 ++++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 strcase/README.md diff --git a/strcase/README.md b/strcase/README.md new file mode 100644 index 0000000..658b6f3 --- /dev/null +++ b/strcase/README.md @@ -0,0 +1,121 @@ +# strcase + +A small, dependency-free Go package for detecting the case of a string and +converting it between the common case styles. + +## Public API + +```go +type Mode int + +const ( + _ Mode = iota + FlatCase + CamelCase + SnakeCase + KebabCase +) + +func Detect(s string) Mode +func Convert(s string) Converter +func (c Converter) To(target Mode) string +func (c Mode) ConvertTo(target Mode, s string) string +``` + +- `Mode` is a case style. `FlatCase` is a lowercase string without separators + (`helloworld`), `CamelCase` capitalizes the word boundaries + (`helloWorld`), `SnakeCase` separates the words with underscores + (`hello_world`), `KebabCase` separates them with dashes (`hello-world`). +- `Detect` returns the case of the given string. +- `Convert` detects the case and returns a `Converter` bound to that string. +- `Converter.To` converts the string to the target case. +- `Mode.ConvertTo` converts a string of a known case to the target case, for the + cases where the detection is not needed. + +## Usage + +```go +import ( + "fmt" + + "github.com/nbgrp/pkg/strcase" +) + +func main() { + fmt.Println(strcase.Convert("hello_world").To(strcase.CamelCase)) // helloWorld + fmt.Println(strcase.Convert("helloWorld").To(strcase.SnakeCase)) // hello_world + fmt.Println(strcase.Convert("hello-world").To(strcase.CamelCase)) // helloWorld +} +``` + +When the case of the string is already known, `Mode.ConvertTo` can be used +directly, skipping the detection: + +```go +fmt.Println(strcase.SnakeCase.ConvertTo(strcase.KebabCase, "hello_world")) // hello-world +``` + +## Detection + +`Detect` looks at the separators first, and then at the letters: + +- a string containing an underscore is a `SnakeCase` one; +- a string containing a dash is a `KebabCase` one; +- a string containing both lower and upper case letters is a `CamelCase` one; +- everything else is a `FlatCase` one. + +`FlatCase` is also the fallback for an empty string, and for a string without +any case marker at all. + +```go +strcase.Detect("hello_world") // strcase.SnakeCase +strcase.Detect("hello-world") // strcase.KebabCase +strcase.Detect("helloWorld") // strcase.CamelCase +strcase.Detect("HELLO_WORLD") // strcase.SnakeCase +strcase.Detect("helloworld") // strcase.FlatCase +strcase.Detect("") // strcase.FlatCase +``` + +Only the first 100 bytes of the string are inspected, so that the detection +stays cheap on long strings. A separator that appears later than that is not +taken into account. + +## Conversion + +The conversion is driven by the detected case of the source string: + +| source | `FlatCase` | `CamelCase` | `SnakeCase` | `KebabCase` | +|-------------------------------|-------------|-------------|-------------|-------------| +| `helloWorld` (`CamelCase`) | `helloworld` | `helloWorld` | `hello_world` | `hello-world` | +| `hello_world` (`SnakeCase`) | `helloworld` | `helloWorld` | `hello_world` | `hello-world` | +| `hello-world` (`KebabCase`) | `helloworld` | `helloWorld` | `hello_world` | `hello-world` | +| `helloworld` (`FlatCase`) | `helloworld` | `helloworld` | `helloworld` | `helloworld` | + +Two rules deserve a note: + +- Converting **from** `FlatCase` is a no-op: the case of such a string is + unknown, so there is nothing to split or to join. +- Converting **to** `CamelCase` from a separated case removes the separator and + capitalizes the following letter; converting from `CamelCase` splits the + string into words. + +## Abbreviations + +When a camel case string is split into words, the abbreviations are recognized +and kept together: + +```go +strcase.Convert("ReadJSONData").To(strcase.SnakeCase) // read_json_data +strcase.Convert("JSONData").To(strcase.SnakeCase) // json_data +strcase.Convert("PlanBDone").To(strcase.SnakeCase) // plan_b_done +``` + +A single letter is treated as a word of its own, and the conversion between two +separated cases (`SnakeCase` and `KebabCase`) only replaces the separator, so +the words themselves are left untouched. + +## Limitations + +The detection is a heuristic: it looks at the shape of the string, not at the +words it contains. A string without any separator and without any upper case +letter is indistinguishable from a `FlatCase` one, and is treated as such. diff --git a/strcase/strcase.go b/strcase/strcase.go index ed77c01..c71038a 100644 --- a/strcase/strcase.go +++ b/strcase/strcase.go @@ -1,5 +1,7 @@ // SPDX-License-Identifier: BSD-3-Clause +// Package strcase provides detection of the case of a string and conversion +// between the supported case styles. package strcase import ( @@ -7,13 +9,21 @@ import ( "unicode" ) +// Mode is a case style of a string. type Mode int const ( _ Mode = iota + // FlatCase is a lowercase string without any separator, e.g. "helloworld". + // It is also the fallback mode, used when the case of a string cannot be + // detected. FlatCase + // CamelCase is a string with capitalized word boundaries and no separator, + // e.g. "helloWorld". CamelCase + // SnakeCase is a lowercase string separated by underscores, e.g. "hello_world". SnakeCase + // KebabCase is a lowercase string separated by dashes, e.g. "hello-world". KebabCase ) @@ -24,11 +34,15 @@ const ( maxWordLength = 100 ) +// Converter converts a string from the case it was detected in to another one. type Converter struct { s string mode Mode } +// Convert detects the case of the given string and returns a converter for it. +// +// Use [Converter.To] to convert the string to another case. func Convert(s string) Converter { return Converter{ mode: Detect(s), @@ -36,10 +50,16 @@ func Convert(s string) Converter { } } +// To converts the string to the given target case. func (c Converter) To(target Mode) string { return c.mode.ConvertTo(target, c.s) } +// ConvertTo converts the string s from the c case to the target case. +// +// Converting a string to the case it already has returns it unchanged. +// [FlatCase] means that the case of the string is unknown, so converting from +// it is a no-op as well. func (c Mode) ConvertTo(target Mode, s string) string { switch { case c == target, c == FlatCase: @@ -53,6 +73,7 @@ func (c Mode) ConvertTo(target Mode, s string) string { } } +// sep returns the separator rune of the mode. func (c Mode) sep() rune { switch c { case SnakeCase: @@ -64,6 +85,18 @@ func (c Mode) sep() rune { } } +// Detect returns the case of the given string. +// +// The detection is based on the separators and on the presence of both lower +// and upper case letters: a string containing an underscore is a snake case +// one, a string containing a dash is a kebab case one, and a string with both +// lower and upper case letters is a camel case one. +// +// Only the first 100 bytes of the string are inspected, so that the detection +// stays cheap on long strings. +// +// An empty string, as well as a string without any case marker, is detected as +// [FlatCase]. func Detect(s string) Mode { if s == "" { return FlatCase // FlatCase used as a fallback value @@ -102,6 +135,8 @@ func Detect(s string) Mode { return FlatCase } +// toCamel removes the separator from the string and capitalizes the letter +// that follows it. func toCamel(s string, sep rune) string { if s == "" { return "" @@ -128,6 +163,10 @@ func toCamel(s string, sep rune) string { return sb.String() } +// fromCamel splits the camel case string into words and joins them with the +// given separator, lowercasing the result. +// +// The abbreviations are handled: "ReadJSONData" becomes "read_json_data". func fromCamel(s string, sep rune) string { if s == "" { return "" @@ -176,6 +215,10 @@ func fromCamel(s string, sep rune) string { return sb.String() } +// replaceSep replaces the fromSep separator with the toSep one. +// +// A noSep source separator means that the string has no separators at all and +// is returned as is; a noSep target separator removes the separators. func replaceSep(s string, fromSep, toSep rune) string { if fromSep == noSep { // flat case or other non-delimited string From 2a90eac4a33ac611dd24dc481e871b82f62a5c64 Mon Sep 17 00:00:00 2001 From: Alexander Menshchikov Date: Thu, 29 Oct 2026 18:55:08 +0300 Subject: [PATCH 06/20] Add sync README and document the package Describe the retry-until-success semantics of SuccessfulOnce and how it differs from sync.Once. Add doc comments to the exported API. --- sync/README.md | 89 ++++++++++++++++++++++++++++++++++++++++++++++++++ sync/once.go | 14 +++++++- 2 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 sync/README.md diff --git a/sync/README.md b/sync/README.md new file mode 100644 index 0000000..7ee7150 --- /dev/null +++ b/sync/README.md @@ -0,0 +1,89 @@ +# sync + +A small, dependency-free Go package with a `sync.Once` that keeps retrying until +the guarded function succeeds. + +## Public API + +```go +type SuccessfulOnce struct { + // contains unexported fields +} + +func (o *SuccessfulOnce) Do(fn func() error) error +``` + +`SuccessfulOnce` is a zero-value ready value: it needs no initialization and +must not be copied after the first use. + +## Usage + +```go +import ( + pkgSync "github.com/nbgrp/pkg/sync" +) + +var once pkgSync.SuccessfulOnce + +err := once.Do(func() error { + return connect() // retried until it succeeds +}) +``` + +The package is named `sync`, just like the standard library one, so it usually +has to be imported under an alias: + +```go +import ( + stdsync "sync" + + pkgSync "github.com/nbgrp/pkg/sync" +) +``` + +## Semantics + +`Do` calls `fn` until it returns a nil error: + +- while `fn` fails, its error is returned to the caller and `fn` is called + again by the next `Do` call; +- the first successful call is remembered, and every `Do` call made after it + returns nil without calling `fn` again. + +```go +var once sync.SuccessfulOnce + +// the first two calls fail, the third one succeeds +err := once.Do(func() error { return errors.New("boom") }) // boom +err = once.Do(func() error { return errors.New("boom") }) // boom +err = once.Do(func() error { return nil }) // nil +err = once.Do(func() error { return errors.New("boom") }) // nil, fn is not called +``` + +The function is only ever called with the arguments of the caller that won the +race to run it — it takes no arguments, so the state it needs is usually closed +over. + +## Comparison with `sync.Once` + +| | `sync.Once` | `SuccessfulOnce` | +|-------------------------|-------------|-----------------------| +| The function may fail | no | yes | +| Retries after a failure | no | yes, on the next call | +| Returns an error | no | yes | +| The function is called | exactly once| until it succeeds | + +`SuccessfulOnce` is the right choice when the guarded operation is idempotent +but may fail for transient reasons — connecting to a service, acquiring a lock +through an external system, initializing a resource. + +## Concurrency + +`Do` is safe for concurrent use. + +The calls are serialized: only one of them runs `fn` at a time, and the others +wait for it. If `fn` fails, the next waiter runs it in turn, so a failing +function is retried by the concurrent callers as well — one attempt per caller. + +The success flag is read without the lock on the fast path, so a `Do` call made +after a success returns immediately, without blocking. diff --git a/sync/once.go b/sync/once.go index c956ce6..e96cda8 100644 --- a/sync/once.go +++ b/sync/once.go @@ -8,12 +8,23 @@ import ( ) // SuccessfulOnce allows to run user function once successfully. +// +// Unlike the standard library sync.Once, the function is retried as long as it +// fails: the call is considered successful only when the function returns a nil +// error, and every call made after that is a no-op. type SuccessfulOnce struct { done atomic.Uint32 mu sync.Mutex } -// Do runs fn if previous fn call returned an error. +// Do runs fn until it succeeds. +// +// The function is called only while it has not succeeded yet. Its error is +// returned to the caller, and the next call retries it. Once fn has succeeded, +// Do returns nil without calling it again. +// +// The calls are serialized: while fn is running, the other callers wait for it +// to finish. func (o *SuccessfulOnce) Do(fn func() error) error { if o.done.Load() == 0 { return o.doSlow(fn) @@ -22,6 +33,7 @@ func (o *SuccessfulOnce) Do(fn func() error) error { return nil } +// doSlow runs fn under the lock, so that only one call is in flight at a time. func (o *SuccessfulOnce) doSlow(fn func() error) error { o.mu.Lock() defer o.mu.Unlock() From 49f8c0690584a93e458ec447bfade12dcafbf806 Mon Sep 17 00:00:00 2001 From: Alexander Menshchikov Date: Thu, 29 Oct 2026 18:55:09 +0300 Subject: [PATCH 07/20] Add time README and document the package Describe how the duration is serialized and which values are accepted by the JSON and the YAML decoders. Add doc comments to the exported API. --- time/README.md | 112 +++++++++++++++++++++++++++++++++++++++++++++++++ time/time.go | 25 +++++++++++ 2 files changed, 137 insertions(+) create mode 100644 time/README.md diff --git a/time/README.md b/time/README.md new file mode 100644 index 0000000..2d500e6 --- /dev/null +++ b/time/README.md @@ -0,0 +1,112 @@ +# time + +A tiny, dependency-free Go package with a duration type that is serialized as a +human-readable string instead of a number of nanoseconds. + +`time.Duration` is a plain `int64`, so it is marshaled to JSON as a bare +number — `1800000000000` instead of `30m0s`. This package provides a type that +keeps the readable form in both JSON and YAML. + +## Public API + +```go +type Duration time.Duration + +func (d Duration) MarshalJSON() ([]byte, error) +func (d *Duration) UnmarshalJSON(b []byte) error +func (d Duration) MarshalYAML() (interface{}, error) +func (d *Duration) UnmarshalYAML(unmarshal func(interface{}) error) error +``` + +`Duration` implements `json.Marshaler`, `json.Unmarshaler` and the YAML +marshaler and unmarshaler interfaces, so it works with `encoding/json` and with +`gopkg.in/yaml.v3` out of the box. + +## Usage + +```go +import ( + "encoding/json" + "fmt" + + pkgTime "github.com/nbgrp/pkg/time" +) + +type Config struct { + Timeout pkgTime.Duration `json:"timeout"` +} + +data, _ := json.Marshal(Config{Timeout: pkgTime.Duration(30 * time.Second)}) +// data == {"timeout":"30s"} +``` + +The package is named `time`, just like the standard library one, so it usually +has to be imported under an alias. + +## Serialization + +A duration is always written as a string, in the format of +[`time.Duration.String`](https://pkg.go.dev/time#Duration.String): + +```go +json.Marshal(pkgTime.Duration(0)) // "0s" +json.Marshal(pkgTime.Duration(30 * time.Second)) // "30s" +json.Marshal(pkgTime.Duration(5 * time.Hour)) // "5h0m0s" +``` + +The same holds for YAML: + +```go +yaml.Marshal(pkgTime.Duration(30 * time.Second)) // "30s\n" +``` + +## Deserialization + +Both a duration string and a number are accepted: + +| value | JSON | YAML | +|------------|-------------------------------|-------------------| +| `"30s"` | 30 seconds | 30 seconds | +| `30` | 30 nanoseconds | error | +| `30.5` | 30 nanoseconds | 30 nanoseconds | +| `"1m30s"` | 1 minute 30 seconds | 1 minute 30 seconds | +| `true` | error | error | + +The number is always a number of **nanoseconds**, the same unit +`time.Duration` uses internally. + +The JSON and the YAML formats differ in the way they decode the numbers: JSON +decodes every number as a `float64`, while YAML decodes an integer as an `int`. +The YAML decoder only accepts the floating point numbers, so a whole number in +a YAML document is rejected: + +```yaml +timeout: 30s # ok +timeout: 30.5 # ok, 30 nanoseconds +timeout: 30 # error: invalid duration +``` + +When in doubt, use the string form: it is the one that is always accepted and +the one that is always written. + +## Errors + +An invalid value is reported as an error, and the duration is left unchanged: + +- a string that is not a valid duration produces the error of + [`time.ParseDuration`](https://pkg.go.dev/time#ParseDuration); +- a value of an unsupported type produces `invalid duration`. + +## Comparison with `time.Duration` + +`Duration` is a defined type, not an alias, so it does not inherit the methods +of `time.Duration`. Convert it back when they are needed: + +```go +d := pkgTime.Duration(30 * time.Second) + +fmt.Println(time.Duration(d).Seconds()) // 30 +``` + +`Duration` is meant to be used in the configuration structures that are +serialized, not as a general-purpose replacement for `time.Duration`. diff --git a/time/time.go b/time/time.go index 4c7b91d..3350557 100644 --- a/time/time.go +++ b/time/time.go @@ -1,5 +1,7 @@ // SPDX-License-Identifier: BSD-3-Clause +// Package time provides a duration type that is serialized as a human-readable +// string instead of a number of nanoseconds. package time import ( @@ -8,6 +10,11 @@ import ( "time" ) +// Duration is a [time.Duration] that is marshaled to JSON and YAML as a +// string, e.g. "1m30s". +// +// It is a defined type, not an alias, so the methods of [time.Duration] are +// not available on it: use a conversion to get them back. type Duration time.Duration var ( @@ -15,10 +22,16 @@ var ( _ json.Unmarshaler = (*Duration)(nil) ) +// MarshalJSON implements [json.Marshaler]. +// +// The duration is written as a string, e.g. "1m30s". func (d Duration) MarshalJSON() ([]byte, error) { return json.Marshal(time.Duration(d).String()) } +// UnmarshalJSON implements [json.Unmarshaler]. +// +// Both a duration string ("1m30s") and a number of nanoseconds are accepted. func (d *Duration) UnmarshalJSON(b []byte) error { var v interface{} if err := json.Unmarshal(b, &v); err != nil { @@ -34,10 +47,18 @@ func (d *Duration) UnmarshalJSON(b []byte) error { return nil } +// MarshalYAML implements the yaml.Marshaler interface. +// +// The duration is written as a string, e.g. "1m30s". func (d Duration) MarshalYAML() (interface{}, error) { return time.Duration(d).String(), nil } +// UnmarshalYAML implements the yaml.Unmarshaler interface. +// +// A duration string ("1m30s") is accepted, as well as a floating point number +// of nanoseconds. A whole number is rejected, because the YAML decoder decodes +// it as an int. func (d *Duration) UnmarshalYAML(unmarshal func(interface{}) error) error { var v interface{} if err := unmarshal(&v); err != nil { @@ -53,6 +74,10 @@ func (d *Duration) UnmarshalYAML(unmarshal func(interface{}) error) error { return nil } +// fromRaw converts a decoded JSON or YAML value into a Duration. +// +// A string is parsed as a duration, a number is treated as a number of +// nanoseconds. Any other value is rejected. func fromRaw(v interface{}) (Duration, error) { switch v := v.(type) { case float64: From f2afebb8cdff64e70ed60a9b8f4fab93d39b4351 Mon Sep 17 00:00:00 2001 From: Alexander Menshchikov Date: Thu, 29 Oct 2026 18:55:09 +0300 Subject: [PATCH 08/20] Document the dispatcher package Add doc comments to the public types and interfaces, and to the trie implementation: the modes, the constructor options and the registration methods. --- dispatcher/dispatcher.go | 19 +++++++++++++++++ dispatcher/trie/trie.go | 46 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/dispatcher/dispatcher.go b/dispatcher/dispatcher.go index 777eebd..17ad121 100644 --- a/dispatcher/dispatcher.go +++ b/dispatcher/dispatcher.go @@ -6,10 +6,18 @@ import ( "context" ) +// StopPropagationError is returned by a handler to stop the dispatch chain. +// +// The handlers that are not called yet are skipped. The Inner error, if it is +// not nil, is returned by the dispatch instead of the StopPropagationError +// itself. type StopPropagationError struct { + // Inner is the error the dispatch reports. It may be nil. Inner error } +// Error returns the message of the error: "stop propagation", followed by the +// message of the Inner error, if there is one. func (e *StopPropagationError) Error() string { msg := "stop propagation" if e.Inner != nil { @@ -18,16 +26,27 @@ func (e *StopPropagationError) Error() string { return msg } +// Handler is a callback invoked on a matching dispatch. +// +// The payload is the one passed to the dispatch call. type Handler func(context.Context, ...any) error +// Dispatcher routes the payload to the handlers registered for the key. type Dispatcher interface { + // Dispatch calls the handlers registered for the key and returns their joined errors. Dispatch(ctx context.Context, key string, payload ...any) error } +// Listener registers the handlers for a key pattern. type Listener interface { + // Listen registers the handler for the key pattern and returns the function + // that cancels the registration. Listen(keyPattern string, handler Handler) (cancel func(), err error) } +// PriorityListener registers the handlers for a key pattern, with a priority. type PriorityListener interface { + // ListenWithPriority registers the handler for the key pattern with the given + // priority and returns the function that cancels the registration. ListenWithPriority(keyPattern string, handler Handler, priority int) (cancel func(), err error) } diff --git a/dispatcher/trie/trie.go b/dispatcher/trie/trie.go index d761dcc..003f7b7 100644 --- a/dispatcher/trie/trie.go +++ b/dispatcher/trie/trie.go @@ -14,10 +14,15 @@ import ( pkgdispatcher "github.com/nbgrp/pkg/dispatcher" ) +// mode is the way the matched handlers are executed. type mode int const ( + // ModePriority runs the handlers sequentially, in the descending order of + // their priority. ModePriority mode = iota + // ModeConcurrent runs all the matched handlers concurrently and waits for + // all of them to finish. ModeConcurrent ) @@ -27,32 +32,44 @@ type options struct { mode mode } +// Option configures a dispatcher created by [NewDispatcher]. type Option func(*options) +// WithKeySeparator sets the separator the keys are split by. +// The default is '.'. func WithKeySeparator(separator rune) Option { return func(opts *options) { opts.keySeparator = separator } } +// WithWildcardMark sets the mark that denotes a wildcard part of a key. +// The default is '*'. func WithWildcardMark(mark rune) Option { return func(opts *options) { opts.wildcardMark = mark } } +// WithMode sets the mode the matched handlers are executed in. +// The default is [ModePriority]. func WithMode(mode mode) Option { return func(opts *options) { opts.mode = mode } } +// nodeHandler is a handler registered on a node of the trie. type nodeHandler struct { handler pkgdispatcher.Handler priority int - deleted atomic.Bool + // deleted is set by the cancel function returned by the Listen methods. + // The handler stays in the trie until it is pruned on the next match. + deleted atomic.Bool } +// node is a part of the trie. It holds the handlers registered for the key +// ending with this part, and the nodes for the next parts. type node struct { children map[string]*node handlers []*nodeHandler @@ -60,6 +77,10 @@ type node struct { mu sync.Mutex } +// purgeDeleted removes the handlers that were cancelled from the node. +// +// It is called on the visited nodes only, so a cancelled handler is pruned +// when a dispatch walks through its node. func (n *node) purgeDeleted() { n.mu.Lock() defer n.mu.Unlock() @@ -80,6 +101,10 @@ type dispatcher struct { opts options } +// NewDispatcher returns a dispatcher with the given options. +// +// The default options are [ModePriority], a '.' key separator and a '*' +// wildcard mark. func NewDispatcher(opts ...Option) (*dispatcher, error) { o := options{ keySeparator: '.', @@ -95,10 +120,20 @@ func NewDispatcher(opts ...Option) (*dispatcher, error) { }, nil } +// Listen registers the handler for the key pattern and returns the function +// that cancels the registration. +// +// The key pattern must not be empty, must not start or end with the separator, +// must not contain two consecutive separators, and the handler must not be nil. func (d *dispatcher) Listen(keyPattern string, handler pkgdispatcher.Handler) (cancel func(), err error) { return d.ListenWithPriority(keyPattern, handler, 0) } +// ListenWithPriority registers the handler for the key pattern with the given +// priority and returns the function that cancels the registration. +// +// The higher the priority, the earlier the handler is called in [ModePriority]. +// The priority is ignored in [ModeConcurrent]. func (d *dispatcher) ListenWithPriority(keyPattern string, handler pkgdispatcher.Handler, priority int) (cancel func(), err error) { switch { case keyPattern == "": @@ -137,6 +172,11 @@ func (d *dispatcher) ListenWithPriority(keyPattern string, handler pkgdispatcher }, nil } +// Dispatch calls the handlers registered for the key and returns their joined +// errors. +// +// The handlers are called in the order that depends on the mode of the +// dispatcher, and the payload is passed to all of them. func (d *dispatcher) Dispatch(ctx context.Context, key string, payload ...any) error { //nolint:gocognit wm := string(d.opts.wildcardMark) var handlers []*nodeHandler @@ -216,6 +256,10 @@ func (d *dispatcher) Dispatch(ctx context.Context, key string, payload ...any) e return errors.Join(errs...) } +// splitKey splits the key into the parts separated by the key separator. +// +// The empty parts are dropped, so a dispatched key with consecutive, leading +// or trailing separators is split into the same parts as a valid one. func (d *dispatcher) splitKey(key string) iter.Seq[string] { return strings.FieldsFuncSeq(key, func(r rune) bool { return r == d.opts.keySeparator From a2ed8f45e864f93e5bb63f51bbba0d3a9ef6d8be Mon Sep 17 00:00:00 2001 From: Alexander Menshchikov Date: Thu, 29 Oct 2026 18:55:09 +0300 Subject: [PATCH 09/20] Add the module index to the root README List every module of the repository with a link and a short description. --- README.md | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index eed8a62..98162d5 100644 --- a/README.md +++ b/README.md @@ -1 +1,37 @@ -# pkg \ No newline at end of file +# pkg + +A collection of small, standalone Go packages. + +Every package is a separate Go module with its own dependencies, so importing +one of them does not pull anything else in. + +## Modules + +| Module | Description | +|---|---| +| [closer](./closer) | Collection of cleanup functions, run once — explicitly, or on a context cancel or an OS signal. | +| [ctxkey](./ctxkey) | Unique, pointer-backed context keys. | +| [deferred](./deferred) | Closing a resource in a deferred call without losing the close error. | +| [dispatcher](./dispatcher) | Routing of keyed events to handlers, with priorities, propagation control and wildcards. | +| [protomap](./protomap) | Deserialization of protobuf messages by their descriptor name. | +| [strcase](./strcase) | Detection of the case of a string and conversion between the case styles. | +| [sync](./sync) | A `sync.Once` that keeps retrying until the guarded function succeeds. | +| [time](./time) | A duration type that is serialized as a human-readable string. | + +Each module has its own README with the description of the API and the +examples of the usage. + +## Usage + +The packages are imported by their module path: + +```go +import ( + "github.com/nbgrp/pkg/closer" + "github.com/nbgrp/pkg/strcase" +) +``` + +## License + +[BSD 3-Clause](./LICENSE) From 4268876d5dd460be9ce8dd68eebc761f12237357 Mon Sep 17 00:00:00 2001 From: Alexander Menshchikov Date: Fri, 30 Oct 2026 10:56:32 +0300 Subject: [PATCH 10/20] Accept the whole numbers in YAML and cover the invalid values The YAML decoder decodes a whole number as an int, so it was rejected before. Now both decoders accept the numbers, and the error of an invalid value mentions its type. Update the README and add the tests for the values that are neither a number nor a valid duration string. --- time/README.md | 35 +++++++------- time/time.go | 13 +++-- time/time_test.go | 121 +++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 145 insertions(+), 24 deletions(-) diff --git a/time/README.md b/time/README.md index 2d500e6..018d4eb 100644 --- a/time/README.md +++ b/time/README.md @@ -49,9 +49,9 @@ A duration is always written as a string, in the format of [`time.Duration.String`](https://pkg.go.dev/time#Duration.String): ```go -json.Marshal(pkgTime.Duration(0)) // "0s" -json.Marshal(pkgTime.Duration(30 * time.Second)) // "30s" -json.Marshal(pkgTime.Duration(5 * time.Hour)) // "5h0m0s" +json.Marshal(pkgTime.Duration(0)) // "0s" +json.Marshal(pkgTime.Duration(30 * time.Second)) // "30s" +json.Marshal(pkgTime.Duration(5 * time.Hour)) // "5h0m0s" ``` The same holds for YAML: @@ -64,26 +64,26 @@ yaml.Marshal(pkgTime.Duration(30 * time.Second)) // "30s\n" Both a duration string and a number are accepted: -| value | JSON | YAML | -|------------|-------------------------------|-------------------| -| `"30s"` | 30 seconds | 30 seconds | -| `30` | 30 nanoseconds | error | -| `30.5` | 30 nanoseconds | 30 nanoseconds | -| `"1m30s"` | 1 minute 30 seconds | 1 minute 30 seconds | -| `true` | error | error | +| value | JSON | YAML | +|-----------|---------------------|---------------------| +| `"30s"` | 30 seconds | 30 seconds | +| `30` | 30 nanoseconds | 30 nanoseconds | +| `30.5` | 30 nanoseconds | 30 nanoseconds | +| `"1m30s"` | 1 minute 30 seconds | 1 minute 30 seconds | +| `true` | error | error | The number is always a number of **nanoseconds**, the same unit `time.Duration` uses internally. -The JSON and the YAML formats differ in the way they decode the numbers: JSON -decodes every number as a `float64`, while YAML decodes an integer as an `int`. -The YAML decoder only accepts the floating point numbers, so a whole number in -a YAML document is rejected: +Both decoders accept the numbers, although they decode them differently: JSON +decodes every number as a `float64`, while YAML decodes a whole number as an +`int` and a fractional one as a `float64`. A fractional number is truncated +towards zero. ```yaml -timeout: 30s # ok +timeout: 30s # ok, 30 seconds +timeout: 30 # ok, 30 nanoseconds timeout: 30.5 # ok, 30 nanoseconds -timeout: 30 # error: invalid duration ``` When in doubt, use the string form: it is the one that is always accepted and @@ -95,7 +95,8 @@ An invalid value is reported as an error, and the duration is left unchanged: - a string that is not a valid duration produces the error of [`time.ParseDuration`](https://pkg.go.dev/time#ParseDuration); -- a value of an unsupported type produces `invalid duration`. +- a value that is neither a number nor a string produces an `invalid duration` + error that mentions the value and its type. ## Comparison with `time.Duration` diff --git a/time/time.go b/time/time.go index 3350557..5d8c8e4 100644 --- a/time/time.go +++ b/time/time.go @@ -6,7 +6,7 @@ package time import ( "encoding/json" - "errors" + "fmt" "time" ) @@ -56,9 +56,7 @@ func (d Duration) MarshalYAML() (interface{}, error) { // UnmarshalYAML implements the yaml.Unmarshaler interface. // -// A duration string ("1m30s") is accepted, as well as a floating point number -// of nanoseconds. A whole number is rejected, because the YAML decoder decodes -// it as an int. +// Both a duration string ("1m30s") and a number of nanoseconds are accepted. func (d *Duration) UnmarshalYAML(unmarshal func(interface{}) error) error { var v interface{} if err := unmarshal(&v); err != nil { @@ -78,10 +76,15 @@ func (d *Duration) UnmarshalYAML(unmarshal func(interface{}) error) error { // // A string is parsed as a duration, a number is treated as a number of // nanoseconds. Any other value is rejected. +// +// The JSON decoder produces the numbers as float64, while the YAML decoder +// produces the whole numbers as int and the fractional ones as float64. func fromRaw(v interface{}) (Duration, error) { switch v := v.(type) { case float64: return Duration(time.Duration(v)), nil + case int: + return Duration(time.Duration(v)), nil case string: dur, err := time.ParseDuration(v) if err != nil { @@ -89,6 +92,6 @@ func fromRaw(v interface{}) (Duration, error) { } return Duration(dur), nil default: - return Duration(0), errors.New("invalid duration") + return Duration(0), fmt.Errorf("invalid duration: %v (%T)", v, v) } } diff --git a/time/time_test.go b/time/time_test.go index 19abc8d..803aa4b 100644 --- a/time/time_test.go +++ b/time/time_test.go @@ -67,10 +67,20 @@ func TestDuration_UnmarshalJSON(t *testing.T) { want: Duration(4*time.Minute + 30*time.Second), }, { - name: "5 millisecond", + name: "5 milliseconds", d: []byte(`"5ms"`), want: Duration(5 * time.Millisecond), }, + { + name: "600 nanoseconds", + d: []byte(`600`), + want: Duration(600 * time.Nanosecond), + }, + { + name: "float 10 nanoseconds", + d: []byte(`10.35`), + want: Duration(10 * time.Nanosecond), + }, } for _, tt := range tests { tt := tt @@ -84,6 +94,57 @@ func TestDuration_UnmarshalJSON(t *testing.T) { } } +func TestDuration_UnmarshalJSON_Error(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + d []byte + wantErr string + }{ + { + name: "invalid duration string", + d: []byte(`"bogus"`), + wantErr: `time: invalid duration "bogus"`, + }, + { + name: "empty string", + d: []byte(`""`), + wantErr: `time: invalid duration ""`, + }, + { + name: "boolean", + d: []byte(`true`), + wantErr: "invalid duration: true (bool)", + }, + { + name: "object", + d: []byte(`{}`), + wantErr: "invalid duration: map[] (map[string]interface {})", + }, + { + name: "array", + d: []byte(`[]`), + wantErr: "invalid duration: [] ([]interface {})", + }, + { + name: "null", + d: []byte(`null`), + wantErr: "invalid duration: ()", + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := Duration(time.Second) + err := json.Unmarshal(tt.d, &got) + require.EqualError(t, err, tt.wantErr) + assert.Equal(t, Duration(time.Second), got) + }) + } +} + func TestDuration_MarshalYAML(t *testing.T) { t.Parallel() @@ -138,10 +199,20 @@ func TestDuration_UnmarshalYAML(t *testing.T) { want: Duration(4*time.Minute + 30*time.Second), }, { - name: "5 millisecond", + name: "5 milliseconds", d: []byte(`5ms`), want: Duration(5 * time.Millisecond), }, + { + name: "600 nanoseconds", + d: []byte(`600`), + want: Duration(600 * time.Nanosecond), + }, + { + name: "float 10 nanoseconds", + d: []byte(`10.35`), + want: Duration(10 * time.Nanosecond), + }, } for _, tt := range tests { tt := tt @@ -154,3 +225,49 @@ func TestDuration_UnmarshalYAML(t *testing.T) { }) } } + +func TestDuration_UnmarshalYAML_Error(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + d []byte + wantErr string + }{ + { + name: "invalid duration string", + d: []byte(`"bogus"`), + wantErr: `time: invalid duration "bogus"`, + }, + { + name: "empty string", + d: []byte(`""`), + wantErr: `time: invalid duration ""`, + }, + { + name: "boolean", + d: []byte(`true`), + wantErr: "invalid duration: true (bool)", + }, + { + name: "object", + d: []byte(`{}`), + wantErr: "invalid duration: map[] (map[string]interface {})", + }, + { + name: "array", + d: []byte(`[]`), + wantErr: "invalid duration: [] ([]interface {})", + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := Duration(time.Second) + err := yaml.Unmarshal(tt.d, &got) + require.EqualError(t, err, tt.wantErr) + assert.Equal(t, Duration(time.Second), got) + }) + } +} From a360e2834b628aaaa28099b6ca23585caab033d0 Mon Sep 17 00:00:00 2001 From: Alexander Menshchikov Date: Fri, 30 Oct 2026 10:56:33 +0300 Subject: [PATCH 11/20] Fix the wording in the deferred README Do not call the package a single helper one: it is going to grow. --- deferred/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deferred/README.md b/deferred/README.md index 03f54f8..3c8699f 100644 --- a/deferred/README.md +++ b/deferred/README.md @@ -1,6 +1,6 @@ # deferred -A tiny, dependency-free Go package with a single helper for closing resources +A tiny, dependency-free Go package with helpers for closing resources in a deferred call without losing the close error. ## Public API From e5aaa09f7a024ba59c8649c5ef737f5c9f66c731 Mon Sep 17 00:00:00 2001 From: Alexander Menshchikov Date: Fri, 30 Oct 2026 10:56:33 +0300 Subject: [PATCH 12/20] Format the strcase README --- strcase/README.md | 35 +++++++++++++++++------------------ 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/strcase/README.md b/strcase/README.md index 658b6f3..c2f3a26 100644 --- a/strcase/README.md +++ b/strcase/README.md @@ -22,10 +22,9 @@ func (c Converter) To(target Mode) string func (c Mode) ConvertTo(target Mode, s string) string ``` -- `Mode` is a case style. `FlatCase` is a lowercase string without separators - (`helloworld`), `CamelCase` capitalizes the word boundaries - (`helloWorld`), `SnakeCase` separates the words with underscores - (`hello_world`), `KebabCase` separates them with dashes (`hello-world`). +- `Mode` is a case style. `FlatCase` is a lowercase string without separators (`helloworld`), + `CamelCase` capitalizes the word boundaries (`helloWorld`), `SnakeCase` separates the words with + underscores (`hello_world`), `KebabCase` separates them with dashes (`hello-world`). - `Detect` returns the case of the given string. - `Convert` detects the case and returns a `Converter` bound to that string. - `Converter.To` converts the string to the target case. @@ -43,7 +42,7 @@ import ( func main() { fmt.Println(strcase.Convert("hello_world").To(strcase.CamelCase)) // helloWorld - fmt.Println(strcase.Convert("helloWorld").To(strcase.SnakeCase)) // hello_world + fmt.Println(strcase.Convert("helloWorld").To(strcase.SnakeCase)) // hello_world fmt.Println(strcase.Convert("hello-world").To(strcase.CamelCase)) // helloWorld } ``` @@ -68,12 +67,12 @@ fmt.Println(strcase.SnakeCase.ConvertTo(strcase.KebabCase, "hello_world")) // he any case marker at all. ```go -strcase.Detect("hello_world") // strcase.SnakeCase -strcase.Detect("hello-world") // strcase.KebabCase -strcase.Detect("helloWorld") // strcase.CamelCase -strcase.Detect("HELLO_WORLD") // strcase.SnakeCase -strcase.Detect("helloworld") // strcase.FlatCase -strcase.Detect("") // strcase.FlatCase +strcase.Detect("hello_world") // strcase.SnakeCase +strcase.Detect("hello-world") // strcase.KebabCase +strcase.Detect("helloWorld") // strcase.CamelCase +strcase.Detect("HELLO_WORLD") // strcase.SnakeCase +strcase.Detect("helloworld") // strcase.FlatCase +strcase.Detect("") // strcase.FlatCase ``` Only the first 100 bytes of the string are inspected, so that the detection @@ -84,12 +83,12 @@ taken into account. The conversion is driven by the detected case of the source string: -| source | `FlatCase` | `CamelCase` | `SnakeCase` | `KebabCase` | -|-------------------------------|-------------|-------------|-------------|-------------| -| `helloWorld` (`CamelCase`) | `helloworld` | `helloWorld` | `hello_world` | `hello-world` | -| `hello_world` (`SnakeCase`) | `helloworld` | `helloWorld` | `hello_world` | `hello-world` | -| `hello-world` (`KebabCase`) | `helloworld` | `helloWorld` | `hello_world` | `hello-world` | -| `helloworld` (`FlatCase`) | `helloworld` | `helloworld` | `helloworld` | `helloworld` | +| source | `FlatCase` | `CamelCase` | `SnakeCase` | `KebabCase` | +|-----------------------------|--------------|--------------|---------------|---------------| +| `helloWorld` (`CamelCase`) | `helloworld` | `helloWorld` | `hello_world` | `hello-world` | +| `hello_world` (`SnakeCase`) | `helloworld` | `helloWorld` | `hello_world` | `hello-world` | +| `hello-world` (`KebabCase`) | `helloworld` | `helloWorld` | `hello_world` | `hello-world` | +| `helloworld` (`FlatCase`) | `helloworld` | `helloworld` | `helloworld` | `helloworld` | Two rules deserve a note: @@ -106,7 +105,7 @@ and kept together: ```go strcase.Convert("ReadJSONData").To(strcase.SnakeCase) // read_json_data -strcase.Convert("JSONData").To(strcase.SnakeCase) // json_data +strcase.Convert("JSONData").To(strcase.SnakeCase) // json_data strcase.Convert("PlanBDone").To(strcase.SnakeCase) // plan_b_done ``` From 6b89c8da86e4cb45428d767c0c975ab5a4434a7a Mon Sep 17 00:00:00 2001 From: Alexander Menshchikov Date: Fri, 30 Oct 2026 10:56:33 +0300 Subject: [PATCH 13/20] Format the sync README --- sync/README.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/sync/README.md b/sync/README.md index 7ee7150..920d19b 100644 --- a/sync/README.md +++ b/sync/README.md @@ -56,8 +56,8 @@ var once sync.SuccessfulOnce // the first two calls fail, the third one succeeds err := once.Do(func() error { return errors.New("boom") }) // boom err = once.Do(func() error { return errors.New("boom") }) // boom -err = once.Do(func() error { return nil }) // nil -err = once.Do(func() error { return errors.New("boom") }) // nil, fn is not called +err = once.Do(func() error { return nil }) // nil +err = once.Do(func() error { return errors.New("boom") }) // nil, fn is not called ``` The function is only ever called with the arguments of the caller that won the @@ -66,12 +66,12 @@ over. ## Comparison with `sync.Once` -| | `sync.Once` | `SuccessfulOnce` | -|-------------------------|-------------|-----------------------| -| The function may fail | no | yes | -| Retries after a failure | no | yes, on the next call | -| Returns an error | no | yes | -| The function is called | exactly once| until it succeeds | +| | `sync.Once` | `SuccessfulOnce` | +|-------------------------|--------------|-----------------------| +| The function may fail | no | yes | +| Retries after a failure | no | yes, on the next call | +| Returns an error | no | yes | +| The function is called | exactly once | until it succeeds | `SuccessfulOnce` is the right choice when the guarded operation is idempotent but may fail for transient reasons — connecting to a service, acquiring a lock From c819847225c4cf368702cd0e51d9a06a4e3b5a81 Mon Sep 17 00:00:00 2001 From: Alexander Menshchikov Date: Fri, 30 Oct 2026 10:56:34 +0300 Subject: [PATCH 14/20] Format the module index in the root README --- README.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 98162d5..18c16d7 100644 --- a/README.md +++ b/README.md @@ -7,16 +7,16 @@ one of them does not pull anything else in. ## Modules -| Module | Description | -|---|---| -| [closer](./closer) | Collection of cleanup functions, run once — explicitly, or on a context cancel or an OS signal. | -| [ctxkey](./ctxkey) | Unique, pointer-backed context keys. | -| [deferred](./deferred) | Closing a resource in a deferred call without losing the close error. | -| [dispatcher](./dispatcher) | Routing of keyed events to handlers, with priorities, propagation control and wildcards. | -| [protomap](./protomap) | Deserialization of protobuf messages by their descriptor name. | -| [strcase](./strcase) | Detection of the case of a string and conversion between the case styles. | -| [sync](./sync) | A `sync.Once` that keeps retrying until the guarded function succeeds. | -| [time](./time) | A duration type that is serialized as a human-readable string. | +| Module | Description | +|----------------------------|-------------------------------------------------------------------------------------------------| +| [closer](./closer) | Collection of cleanup functions, run once — explicitly, or on a context cancel or an OS signal. | +| [ctxkey](./ctxkey) | Unique, pointer-backed context keys. | +| [deferred](./deferred) | Closing a resource in a deferred call without losing the close error. | +| [dispatcher](./dispatcher) | Routing of keyed events to handlers, with priorities, propagation control and wildcards. | +| [protomap](./protomap) | Deserialization of protobuf messages by their descriptor name. | +| [strcase](./strcase) | Detection of the case of a string and conversion between the case styles. | +| [sync](./sync) | A `sync.Once` that keeps retrying until the guarded function succeeds. | +| [time](./time) | A duration type that is serialized as a human-readable string. | Each module has its own README with the description of the API and the examples of the usage. From 6e0ab54e4fb6f6d1d21a350be2d3dac25910b72e Mon Sep 17 00:00:00 2001 From: Alexander Menshchikov Date: Fri, 30 Oct 2026 10:57:57 +0300 Subject: [PATCH 15/20] Rename the context key variable in the ctxkey README --- ctxkey/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ctxkey/README.md b/ctxkey/README.md index eea3cf5..4aee398 100644 --- a/ctxkey/README.md +++ b/ctxkey/README.md @@ -31,14 +31,14 @@ import ( "github.com/nbgrp/pkg/ctxkey" ) -var userID = ctxkey.New("user_id") +var userIDKey = ctxkey.New("user_id") func WithUserID(ctx context.Context, id int64) context.Context { - return context.WithValue(ctx, userID, id) + return context.WithValue(ctx, userIDKey, id) } func UserID(ctx context.Context) (int64, bool) { - id, ok := ctx.Value(userID).(int64) + id, ok := ctx.Value(userIDKey).(int64) return id, ok } ``` From 2eb62177efe07caacefa6362eaca5d57d85f7852 Mon Sep 17 00:00:00 2001 From: Alexander Menshchikov Date: Fri, 30 Oct 2026 11:18:30 +0300 Subject: [PATCH 16/20] Rework the deferred README Describe the package as a collection of the helpers for the defer calls and move the description of Close into its own section. Drop the package doc comment: the README covers the package better. --- deferred/README.md | 15 +++++++++++---- deferred/deferred.go | 2 -- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/deferred/README.md b/deferred/README.md index 3c8699f..c8f6c9d 100644 --- a/deferred/README.md +++ b/deferred/README.md @@ -1,10 +1,17 @@ # deferred -A tiny, dependency-free Go package with helpers for closing resources -in a deferred call without losing the close error. +A tiny, dependency-free Go package with helpers for the `defer` calls. + +The deferred calls have a few sharp edges of their own: a resource that has to +be closed without losing the close error, a cleanup that has to happen even if +the function panics, a value that has to be measured after the deferred +functions have run. The helpers here take care of such cases, so that they do +not have to be reinvented in every project. ## Public API +### `Close` + ```go func Close(err *error, closer io.Closer) ``` @@ -33,7 +40,7 @@ usual `defer f.Close()` swallows it: defer f.Close() // the error is dropped ``` -## Semantics +#### Semantics - The closer is **always** closed, even if `err` is nil. - If `err` is nil, the close error is discarded — the closer is still closed, @@ -58,7 +65,7 @@ if errors.Is(err, os.ErrNotExist) { } ``` -## Ordering +#### Ordering `defer` runs the deferred calls in reverse order, so the resources are closed in the opposite order to the one in which they were opened. This is the behavior diff --git a/deferred/deferred.go b/deferred/deferred.go index 0e6aa30..5cd8cae 100644 --- a/deferred/deferred.go +++ b/deferred/deferred.go @@ -1,7 +1,5 @@ // SPDX-License-Identifier: BSD-3-Clause -// Package deferred provides a helper to close a resource in a deferred call -// without losing the close error. package deferred import ( From 8602b278fc042012868f3352d19fb0c7a66b17b7 Mon Sep 17 00:00:00 2001 From: Alexander Menshchikov Date: Fri, 30 Oct 2026 11:18:31 +0300 Subject: [PATCH 17/20] Update the deferred description in the root README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 18c16d7..f4f296b 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ one of them does not pull anything else in. |----------------------------|-------------------------------------------------------------------------------------------------| | [closer](./closer) | Collection of cleanup functions, run once — explicitly, or on a context cancel or an OS signal. | | [ctxkey](./ctxkey) | Unique, pointer-backed context keys. | -| [deferred](./deferred) | Closing a resource in a deferred call without losing the close error. | +| [deferred](./deferred) | Helpers for the `defer` calls. | | [dispatcher](./dispatcher) | Routing of keyed events to handlers, with priorities, propagation control and wildcards. | | [protomap](./protomap) | Deserialization of protobuf messages by their descriptor name. | | [strcase](./strcase) | Detection of the case of a string and conversion between the case styles. | From 55b60d7d1dc05af49a19da2e83e21e72612ecd72 Mon Sep 17 00:00:00 2001 From: Alexander Menshchikov Date: Fri, 30 Oct 2026 11:18:31 +0300 Subject: [PATCH 18/20] Format the closer README --- closer/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/closer/README.md b/closer/README.md index 765818c..977a27d 100644 --- a/closer/README.md +++ b/closer/README.md @@ -107,7 +107,7 @@ cl.Add(func(ctx context.Context) error { }) <-ctx.Done() // the context is canceled on SIGINT/SIGTERM -<-cl.Done() // ... and the closer is closed right after that +<-cl.Done() // ... and the closer is closed right after that ``` ## Closing From 51740daf43bfe3fc325f08e6dd31f7fbb45ec6de Mon Sep 17 00:00:00 2001 From: Alexander Menshchikov Date: Fri, 30 Oct 2026 11:18:31 +0300 Subject: [PATCH 19/20] Adjust the fractional duration in the time tests --- time/time_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/time/time_test.go b/time/time_test.go index 803aa4b..a17b673 100644 --- a/time/time_test.go +++ b/time/time_test.go @@ -78,7 +78,7 @@ func TestDuration_UnmarshalJSON(t *testing.T) { }, { name: "float 10 nanoseconds", - d: []byte(`10.35`), + d: []byte(`10.5`), want: Duration(10 * time.Nanosecond), }, } @@ -210,7 +210,7 @@ func TestDuration_UnmarshalYAML(t *testing.T) { }, { name: "float 10 nanoseconds", - d: []byte(`10.35`), + d: []byte(`10.5`), want: Duration(10 * time.Nanosecond), }, } From d5ef3f98c940e93caf596ba61bce51cccf2a7f05 Mon Sep 17 00:00:00 2001 From: Alexander Menshchikov Date: Fri, 30 Oct 2026 11:30:28 +0300 Subject: [PATCH 20/20] Clarify the StopPropagationError description The error is only a signal: the dispatch never reports it, it reports the Inner error in its place, and nothing at all when the Inner error is nil. Fix the wording of the README accordingly. --- dispatcher/README.md | 16 +++++++++++----- dispatcher/dispatcher.go | 10 ++++++---- dispatcher/trie/trie.go | 10 ++-------- 3 files changed, 19 insertions(+), 17 deletions(-) diff --git a/dispatcher/README.md b/dispatcher/README.md index 3669738..a1624cf 100644 --- a/dispatcher/README.md +++ b/dispatcher/README.md @@ -51,8 +51,12 @@ type StopPropagationError struct { ``` A handler may return a `*StopPropagationError` to short-circuit dispatch and (optionally) -propagate an inner error. See each backend's documentation for the exact semantics — in -particular, `ModePriority` and `ModeConcurrent` interpret it differently. +propagate an inner error. The `StopPropagationError` itself is never reported by the +dispatch — it is only a signal. When the `Inner` error is nil, the dispatch reports no +error for that handler at all. + +See each backend's documentation for the exact semantics — in particular, `ModePriority` +and `ModeConcurrent` interpret it differently. ## Implementations @@ -106,7 +110,8 @@ Handlers are sorted in **descending** order of their `priority` and called seque Handlers registered with the same priority keep their registration order (sort is stable). Returning a `*dispatcher.StopPropagationError` from a handler stops the chain: handlers with lower priority are **not** called. If `StopPropagationError.Inner` is non-nil, it is -returned by `Dispatch`; otherwise `Dispatch` returns `nil`. +reported by `Dispatch`; otherwise the stop itself contributes no error, and `Dispatch` +returns the joined errors of the handlers that have run. ```go d, _ := trie.NewDispatcher(trie.WithMode(trie.ModePriority)) @@ -213,8 +218,9 @@ wildcard mark. Anything else is fixed at construction time. #### Errors `Dispatch` returns `errors.Join(errs...)` of every matched handler's return value (after -unwrapping `StopPropagationError` per the rules above). It returns `nil` only when every -handler returned `nil` (or a `StopPropagationError` without an `Inner`). +unwrapping `StopPropagationError` per the rules above). The `StopPropagationError` itself +never appears in the result. `Dispatch` returns `nil` only when every handler returned +`nil` (or a `StopPropagationError` without an `Inner`). A handler may short-circuit the chain in `ModePriority` by returning: diff --git a/dispatcher/dispatcher.go b/dispatcher/dispatcher.go index 17ad121..2d64169 100644 --- a/dispatcher/dispatcher.go +++ b/dispatcher/dispatcher.go @@ -8,11 +8,13 @@ import ( // StopPropagationError is returned by a handler to stop the dispatch chain. // -// The handlers that are not called yet are skipped. The Inner error, if it is -// not nil, is returned by the dispatch instead of the StopPropagationError -// itself. +// The handlers that are not called yet are skipped. The error itself is never +// reported by the dispatch, it is only a signal: the Inner error is reported +// in its place, and when the Inner error is nil, the dispatch reports no error +// at all. type StopPropagationError struct { - // Inner is the error the dispatch reports. It may be nil. + // Inner is the error the dispatch reports in place of the + // StopPropagationError itself. It may be nil. Inner error } diff --git a/dispatcher/trie/trie.go b/dispatcher/trie/trie.go index 003f7b7..b6643eb 100644 --- a/dispatcher/trie/trie.go +++ b/dispatcher/trie/trie.go @@ -227,9 +227,7 @@ func (d *dispatcher) Dispatch(ctx context.Context, key string, payload ...any) e for i, h := range handlers { err := h.handler(ctx, payload...) if stopPropagation, ok := errors.AsType[*pkgdispatcher.StopPropagationError](err); ok { - if stopPropagation.Inner != nil { - errs[i] = stopPropagation.Inner - } + errs[i] = stopPropagation.Inner break } errs[i] = err @@ -241,11 +239,7 @@ func (d *dispatcher) Dispatch(ctx context.Context, key string, payload ...any) e wg.Go(func() { err := h.handler(ctx, payload...) if stopPropagation, ok := errors.AsType[*pkgdispatcher.StopPropagationError](err); ok { - if stopPropagation.Inner != nil { - err = stopPropagation.Inner - } else { - err = nil - } + err = stopPropagation.Inner } errs[i] = err })