From 9c1767be6eabeccdf2112a323291848016a4c10c Mon Sep 17 00:00:00 2001 From: Leo Antunes Date: Fri, 9 Oct 2026 18:05:29 +0200 Subject: [PATCH 1/6] test: cover calls with a cancellable context Every existing benchmark calls with context.Background, which skips the cancellation watchdog, so its cost never showed up although nearly all real request contexts are cancellable. The new test pins down that the watchdog is released once a call returns: servers cancel each request's context after the handler, and a still registered watchdog would pile up on long-lived contexts. Co-Authored-By: Claude Opus 5.5 --- hoglet_test.go | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/hoglet_test.go b/hoglet_test.go index 0500e10..a59e44c 100644 --- a/hoglet_test.go +++ b/hoglet_test.go @@ -3,6 +3,7 @@ package hoglet import ( "context" "errors" + "sync/atomic" "testing" "testing/synctest" "time" @@ -55,6 +56,29 @@ func BenchmarkHoglet_Do_EWMA(b *testing.B) { }) } +func BenchmarkHoglet_Do_EWMA_cancellable(b *testing.B) { + // Each goroutine calls with its own cancellable context, like concurrent requests each carrying theirs, so the + // circuit watches it for cancellation. + noop := func(context.Context, struct{}) (out struct{}, err error) { return } + h, err := NewCircuit( + NewEWMABreaker(10, 0.9), + WithHalfOpenDelay(time.Second), + ) + require.NoError(b, err) + + b.ReportAllocs() + b.ResetTimer() + + f := Wrap(h, noop) + b.RunParallel(func(pb *testing.PB) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + for pb.Next() { + _, _ = f(ctx, struct{}{}) + } + }) +} + func BenchmarkHoglet_Do_SlidingWindow(b *testing.B) { noop := func(context.Context, struct{}) (out struct{}, err error) { return } @@ -326,6 +350,28 @@ func TestCircuit_context_error_is_observed_before_wrapped_function_returns(t *te }) } +func TestCircuit_watchdog_stops_when_the_call_returns(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + var consulted atomic.Int64 + c, err := NewCircuit(&mockBreaker{}, WithHalfOpenDelay(time.Minute), WithFailureCondition(func(error) bool { + consulted.Add(1) + return true + })) + require.NoError(t, err) + + // e.g. a server cancels each request's context once its handler returns + ctx, cancel := context.WithCancel(context.Background()) + _, err = Wrap(c, noop)(ctx, noopInSuccess) + require.NoError(t, err) + + cancel() + synctest.Wait() // give a still registered watchdog the chance to fire + // A watchdog left registered would keep piling up on long-lived contexts until they end. + assert.Zero(t, consulted.Load(), "the watchdog must be unregistered once the call returned") + assert.Equal(t, StateClosed, c.State()) + }) +} + func TestCircuit_failure_condition_never_called_with_nil_error(t *testing.T) { conditionCalled := false condition := func(err error) bool { From 76dc817d07a8b179493645c153243fe0c2eb30d7 Mon Sep 17 00:00:00 2001 From: Leo Antunes Date: Fri, 9 Oct 2026 18:05:30 +0200 Subject: [PATCH 2/6] perf: watch the call's context with AfterFunc instead of a goroutine Calls with a cancellable context, i.e. nearly every request, started a goroutine and a child context per call just to notice a cancellation early. A CPU profile showed starting, scheduling and tearing down that goroutine taking most of each call, and the child context most of the rest. context.AfterFunc registers a callback instead and only starts a goroutine if the context actually gets canceled during the call, so cancellations are still recorded as promptly. The internal cancellation cause existed only to tell the goroutine's own wake-up apart from a real cancellation and goes away with it. benchstat, n=10, main and this commit's test binaries run interleaved: ./main.test|./new.test -test.run '^$' -test.bench BenchmarkHoglet_Do_ \ -test.benchmem -test.benchtime 1s -test.cpu 1,8,24 -test.count 1 Hoglet_Do_EWMA_cancellable 1471.0n 513.6n -65.08% (p=0.000) Hoglet_Do_EWMA_cancellable-8 609.6n 127.5n -79.08% (p=0.000) Hoglet_Do_EWMA_cancellable-24 455.1n 122.3n -73.14% (p=0.000) Still 4 allocs/op, 177 -> 224 B/op. Benchmarks with an uncancellable context are unchanged within noise. Co-Authored-By: Claude Opus 5.5 --- hoglet.go | 46 +++++++++++++--------------------------------- 1 file changed, 13 insertions(+), 33 deletions(-) diff --git a/hoglet.go b/hoglet.go index 632c438..22a9f62 100644 --- a/hoglet.go +++ b/hoglet.go @@ -2,7 +2,6 @@ package hoglet import ( "context" - "errors" "fmt" "sync" "sync/atomic" @@ -283,13 +282,11 @@ func Wrap[IN, OUT any](c *Circuit, f WrappableFunc[IN, OUT]) WrappableFunc[IN, O return out, err } - // The watchdog goroutine exists to record a context cancellation/deadline as a failure promptly, even if the - // wrapped function ignores its context and blocks. If the context can never be canceled (no deadline and no - // cancellation, e.g. [context.Background]), the watchdog can never fire usefully, so we skip it and the - // associated context allocation entirely, relying solely on the deferred observation below. - // - // TODO: allow skipping the watchdog via an option for callers that guarantee their wrapped function respects - // its context, trading prompt cancellation detection for one less goroutine + context allocation per call. + // The watchdog records a context cancellation/deadline as a failure promptly, even if the wrapped function + // ignores its context and blocks. It is a callback registered on the context, so it only costs a goroutine if + // the context is actually canceled while the call runs. A context that can never be canceled (no deadline and + // no cancellation, e.g. [context.Background]) cannot trigger it, so we skip it entirely, relying solely on the + // deferred observation below. if ctx.Done() != nil { // Only here can the watchdog race the deferred observe, so dedup to ensure the - potentially wrapped - // observer is observed exactly once. Without a watchdog the deferred func below is the sole observer @@ -297,10 +294,14 @@ func Wrap[IN, OUT any](c *Circuit, f WrappableFunc[IN, OUT]) WrappableFunc[IN, O // This relies on breaker middleware observing synchronously; an async middleware observer must dedup itself. obs = dedupObservableCall(obs) - obsCtx, cancel := context.WithCancelCause(ctx) - defer cancel(errWrappedFunctionDone) - - go c.observeCtx(obs, obsCtx) + // Only a failure is worth recording early. Anything else is left to the wrapped function's actual result: + // recording a success here would mask whatever the function ends up returning. + stop := context.AfterFunc(ctx, func() { + if c.isFailure(ctx.Err()) { + obs.Observe(true) + } + }) + defer stop() } defer func() { @@ -316,27 +317,6 @@ func Wrap[IN, OUT any](c *Circuit, f WrappableFunc[IN, OUT]) WrappableFunc[IN, O } } -// errWrappedFunctionDone is used to distinguish between internal and external (to the lib) context cancellations. -var errWrappedFunctionDone = errors.New("wrapped function done") - -// observeCtx observes the given context for cancellation and records it as a failure. -// It assumes [Observer] is idempotent and deduplicates calls itself. -func (c *Circuit) observeCtx(obs Observer, ctx context.Context) { - // We want to observe a context error as soon as possible to open the breaker, but at the same time we want to - // keep the call to the wrapped function synchronous to avoid all pitfalls that come with asynchronicity. - <-ctx.Done() - - if context.Cause(ctx) == errWrappedFunctionDone { - return // internal cancellation: the wrapped function returned already and is observed by its caller - } - - // Only a failure is worth recording early. Anything else is left to the wrapped function's actual result: - // recording a success here would mask whatever the function ends up returning. - if c.isFailure(ctx.Err()) { - obs.Observe(true) - } -} - // State represents the state of a circuit. type State int From adf101d047135a4e66983506fc41653882edfe0c Mon Sep 17 00:00:00 2001 From: Leo Antunes Date: Fri, 9 Oct 2026 18:44:50 +0200 Subject: [PATCH 3/6] test: count watchdog registrations instead of failure-condition calls The previous test only noticed a leaked watchdog because the callback consults the failure condition before the deduplicated observe, and its state assertion could never fail since the dedup had already fired. A leaked watchdog that decided inside the dedup passed it. A context's AfterFunc method, which context.AfterFunc documents as its registration hook, counts registrations and stops directly, without synctest or a cancellation; both mutants now fail it. Co-Authored-By: Claude Opus 5.5 --- hoglet_test.go | 46 ++++++++++++++++++++++++++++------------------ 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/hoglet_test.go b/hoglet_test.go index a59e44c..bdb889d 100644 --- a/hoglet_test.go +++ b/hoglet_test.go @@ -350,26 +350,36 @@ func TestCircuit_context_error_is_observed_before_wrapped_function_returns(t *te }) } -func TestCircuit_watchdog_stops_when_the_call_returns(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - var consulted atomic.Int64 - c, err := NewCircuit(&mockBreaker{}, WithHalfOpenDelay(time.Minute), WithFailureCondition(func(error) bool { - consulted.Add(1) - return true - })) - require.NoError(t, err) +// watchedContext is a never canceled context counting the callbacks [context.AfterFunc] registers on it, and how many +// of them it unregisters again. +type watchedContext struct { + context.Context + done chan struct{} + registered, stopped atomic.Int64 +} - // e.g. a server cancels each request's context once its handler returns - ctx, cancel := context.WithCancel(context.Background()) - _, err = Wrap(c, noop)(ctx, noopInSuccess) - require.NoError(t, err) +func (w *watchedContext) Done() <-chan struct{} { return w.done } - cancel() - synctest.Wait() // give a still registered watchdog the chance to fire - // A watchdog left registered would keep piling up on long-lived contexts until they end. - assert.Zero(t, consulted.Load(), "the watchdog must be unregistered once the call returned") - assert.Equal(t, StateClosed, c.State()) - }) +// AfterFunc is used by [context.AfterFunc] instead of its own registration. +func (w *watchedContext) AfterFunc(func()) (stop func() bool) { + w.registered.Add(1) + return func() bool { + w.stopped.Add(1) + return true + } +} + +func TestCircuit_watchdog_is_unregistered_when_the_call_returns(t *testing.T) { + c, err := NewCircuit(&mockBreaker{}) + require.NoError(t, err) + + ctx := &watchedContext{Context: context.Background(), done: make(chan struct{})} + _, err = Wrap(c, noop)(ctx, noopInSuccess) + require.NoError(t, err) + + // A watchdog left registered would pile up on long-lived contexts until they end. + require.EqualValues(t, 1, ctx.registered.Load(), "a cancellable context must be watched") + assert.EqualValues(t, 1, ctx.stopped.Load(), "the watchdog must be unregistered once the call returned") } func TestCircuit_failure_condition_never_called_with_nil_error(t *testing.T) { From 03c295c4aac11d4d63a362abe0f603efec8775b0 Mon Sep 17 00:00:00 2001 From: Leo Antunes Date: Fri, 9 Oct 2026 18:44:50 +0200 Subject: [PATCH 4/6] perf: allocate the watchdog callback outside the generic Wrap A closure created inside the generic Wrap is allocated one size class bigger than the same closure in a plain method (64 instead of 48 bytes, per memory profile), so the callback moves into Circuit.watch, which is still inlined. The two comment blocks in Wrap that repeated each other and Wrap's doc comment are trimmed with it. benchstat, n=10, previous and this commit's test binaries run interleaved: ./prev.test|./new.test -test.run '^$' -test.bench BenchmarkHoglet_Do_ \ -test.benchmem -test.benchtime 1s -test.cpu 1,8,24 -test.count 1 Hoglet_Do_EWMA_cancellable 508.9n 508.2n ~ (p=0.617) Hoglet_Do_EWMA_cancellable-8 133.1n 125.9n -5.41% (p=0.001) Hoglet_Do_EWMA_cancellable-24 125.6n 110.5n -12.02% (p=0.000) Still 4 allocs/op, 224 -> 208 B/op. Co-Authored-By: Claude Opus 5.5 --- hoglet.go | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/hoglet.go b/hoglet.go index 22a9f62..9692f7e 100644 --- a/hoglet.go +++ b/hoglet.go @@ -282,11 +282,8 @@ func Wrap[IN, OUT any](c *Circuit, f WrappableFunc[IN, OUT]) WrappableFunc[IN, O return out, err } - // The watchdog records a context cancellation/deadline as a failure promptly, even if the wrapped function - // ignores its context and blocks. It is a callback registered on the context, so it only costs a goroutine if - // the context is actually canceled while the call runs. A context that can never be canceled (no deadline and - // no cancellation, e.g. [context.Background]) cannot trigger it, so we skip it entirely, relying solely on the - // deferred observation below. + // The watchdog is a callback on the context (see [Wrap] for why), so it only costs a goroutine if the context is + // canceled during the call. A context that can never be canceled (e.g. [context.Background]) needs none. if ctx.Done() != nil { // Only here can the watchdog race the deferred observe, so dedup to ensure the - potentially wrapped - // observer is observed exactly once. Without a watchdog the deferred func below is the sole observer @@ -294,13 +291,7 @@ func Wrap[IN, OUT any](c *Circuit, f WrappableFunc[IN, OUT]) WrappableFunc[IN, O // This relies on breaker middleware observing synchronously; an async middleware observer must dedup itself. obs = dedupObservableCall(obs) - // Only a failure is worth recording early. Anything else is left to the wrapped function's actual result: - // recording a success here would mask whatever the function ends up returning. - stop := context.AfterFunc(ctx, func() { - if c.isFailure(ctx.Err()) { - obs.Observe(true) - } - }) + stop := c.watch(ctx, obs) defer stop() } @@ -317,6 +308,18 @@ func Wrap[IN, OUT any](c *Circuit, f WrappableFunc[IN, OUT]) WrappableFunc[IN, O } } +// watch registers the watchdog recording a context error on ctx as a failure, and returns the function unregistering +// it. It is kept out of the generic [Wrap], where the callback would be allocated one size class bigger. +func (c *Circuit) watch(ctx context.Context, obs Observer) (stop func() bool) { + return context.AfterFunc(ctx, func() { + // Only a failure is worth recording early. Anything else is left to the wrapped function's actual result: + // recording a success here would mask whatever the function ends up returning. + if c.isFailure(ctx.Err()) { + obs.Observe(true) + } + }) +} + // State represents the state of a circuit. type State int From c3b3dfa3c9e5b50f21d61b18a369f20717e46c26 Mon Sep 17 00:00:00 2001 From: Leo Antunes Date: Fri, 9 Oct 2026 18:51:17 +0200 Subject: [PATCH 5/6] docs: link the error sentinels in the Wrap comment The comment named ErrConcurrencyLimit, which does not exist; the sentinel is ErrConcurrencyLimitReached. Bracketing both names makes them doc links, so renames are caught. Co-Authored-By: Claude Sonnet 5.5 --- hoglet.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hoglet.go b/hoglet.go index 9692f7e..498510c 100644 --- a/hoglet.go +++ b/hoglet.go @@ -275,8 +275,8 @@ func Wrap[IN, OUT any](c *Circuit, f WrappableFunc[IN, OUT]) WrappableFunc[IN, O if err != nil { // Note: any errors here are not "observed" and do not count towards the breaker's failure rate. // This includes: - // - ErrCircuitOpen - // - ErrConcurrencyLimit (for blocking limited circuits) + // - [ErrCircuitOpen] + // - [ErrConcurrencyLimitReached] (for blocking limited circuits) // - context timeouts while blocked on concurrency limit // And any other errors that may be returned by optional breaker wrappers. return out, err From 9a5a7cb39ba58e0ca9a971490f101890fef89068 Mon Sep 17 00:00:00 2001 From: Leo Antunes Date: Fri, 9 Oct 2026 18:59:31 +0200 Subject: [PATCH 6/6] docs: say which limiter mode returns which error in the Wrap comment ErrConcurrencyLimitReached is what a non-blocking limiter returns, not a blocking one; a blocking limiter returns ErrWaitingForSlot, which the comment described without naming. Co-Authored-By: Claude Sonnet 5.5 --- hoglet.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hoglet.go b/hoglet.go index 498510c..6adcb47 100644 --- a/hoglet.go +++ b/hoglet.go @@ -276,8 +276,8 @@ func Wrap[IN, OUT any](c *Circuit, f WrappableFunc[IN, OUT]) WrappableFunc[IN, O // Note: any errors here are not "observed" and do not count towards the breaker's failure rate. // This includes: // - [ErrCircuitOpen] - // - [ErrConcurrencyLimitReached] (for blocking limited circuits) - // - context timeouts while blocked on concurrency limit + // - [ErrConcurrencyLimitReached] (for non-blocking limited circuits) + // - [ErrWaitingForSlot] (for blocking limited circuits: context errors while waiting for a slot) // And any other errors that may be returned by optional breaker wrappers. return out, err }