Skip to content

fix(close): exempt internal teardown hooks from CloseCallbackBudget - #153

Merged
benitogf merged 1 commit into
fix/stream-cache-single-writerfrom
fix/151-close-budget-internal-hooks
Jul 28, 2026
Merged

fix(close): exempt internal teardown hooks from CloseCallbackBudget#153
benitogf merged 1 commit into
fix/stream-cache-single-writerfrom
fix/151-close-budget-internal-hooks

Conversation

@benitogf

Copy link
Copy Markdown
Owner

Closes #151.

Stacked on #150 — based on that branch (it adds stopResync, one of the two hooks routed here). GitHub auto-retargets this to main when #150 merges; review/merge #150 first.

What & why

Server.Close runs registered close hooks per phase, and once a positive CloseCallbackBudget is exhausted it skips the not-yet-started hooks in that phase. Internal, lifecycle-critical hooks were registered through the same public RegisterCloseHook path as user callbacks, so a tight budget could skip them:

  • stopResync (the resync-worker drain, PreShutdown) — if skipped, the worker goroutine outlives Storage.Close: a goroutine leak and a possible read-after-close on embedded storages.
  • LimitFilter.StopCleanup (PreShutdown) — same exposure.

The default budget of 0 skips nothing, so this was latent — but an operator who sets a budget to bound user callbacks would silently break internal shutdown.

The change

Close hooks now carry an exempt flag:

  • Public API unchanged. RegisterCloseHook (and the deprecated RegisterPreClose/RegisterProxyCleanup wrappers) register budgeted user hooks exactly as before.
  • A new unexported registerInternalCloseHook marks a hook exempt; runPhase never skips an exempt hook regardless of the budget, while non-exempt hooks are still skipped once it's exhausted (and the skip-count log counts only those).
  • stopResync and LimitFilter.StopCleanup route through the internal path. Proxy-teardown hooks stay budgeted (they're proxy/user-owned).

Tests

  • TestCloseCallbackBudgetStopsResyncWorker drives the real defect path: the budget-buster is registered before Start, so stopResync (registered during Start) lands after it in the PreShutdown slice and is only reached after the budget is exhausted. It asserts the worker actually stopped (resyncClosed set only by stopResync, plus a bounded resyncWg join). Verified fault-detecting — it fails if stopResync is routed back through the budgeted path.
  • TestCloseCallbackBudgetExemptsInternalHooks proves the mechanism directly (exempt sentinels run, a user hook is skipped, an exempt hook registered after the budget is blown still runs).
  • Existing CloseCallbackBudget tests pass unmodified; full suite green under go test ./... -race -count=1.

Docs (Server.Close "Bound:" section and README) now state the internal-hook exemption.

🤖 Generated with Claude Code

CloseCallbackBudget skips not-yet-started close hooks once exhausted, but
internal lifecycle hooks (stopResync, LimitFilter.StopCleanup) were registered
through the same public path as user callbacks — so a tight budget could skip
them, leaking the resync worker past Storage.Close.

Close hooks now carry an exempt flag. RegisterCloseHook (public API, unchanged)
registers budgeted user hooks; a new internal registerInternalCloseHook marks
lifecycle hooks exempt, and runPhase never skips an exempt hook regardless of
the budget. stopResync and LimitFilter.StopCleanup route through it. User
callbacks stay budgeted exactly as before.

Closes #151

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@CBosch101 CBosch101 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixes #151 correctly: internal lifecycle teardown is now unskippable under a tight CloseCallbackBudget, while user callbacks stay budget-skippable exactly as before. Verified, no blockers.

Verified

  • Close loop guard is !hook.exempt && budgetExceeded() (ooo.go:1341-1345) — exempt hooks always run; only non-exempt skips increment skipped.
  • Both internal teardown callers routed through the exempt path: stopResync (ooo.go:913) and LimitFilter.StopCleanup (filters.go:167). No user callers changed — public RegisterCloseHook signature and its range guard are unchanged, so no exported-API break.
  • TestCloseCallbackBudgetStopsResyncWorker drives the real #151 path — user budget-buster registered before Start so stopResync lands after it and is reached only post-exhaustion; asserts resyncClosed and a bounded resyncWg re-join. TestCloseCallbackBudgetExemptsInternalHooks covers exempt-after-exhaustion.
  • README and the Close doc comment both updated consistently. go build ./... and go vet ./... clean at HEAD.

🤖 Generated with Claude Code

@benitogf
benitogf merged commit a602dac into fix/stream-cache-single-writer Jul 28, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants