Dosu LogoDosu Logo
Ask
Join our Discord
kyvernoPublic
Nirmata
Documentskyverno
Report Controller Goroutine Lifecycle
Report Controller Goroutine Lifecycle
Type
Topic
Status
Published
Created
Aug 9, 2026
Updated
Sep 1, 2026
Created by
Dosu Bot
Updated by
Dosu Bot

Report Controller Goroutine Lifecycle#

Kyverno's report subsystem has a recurring class of goroutine lifecycle bugs: goroutines spawned during construction (rather than in Run), hardcoded context.Background() / context.TODO() in long-lived watchers, and missing cleanup paths on initialization failure. The three main sites are the aggregate report controller, the resource-report watcher, and the circuit-breaker initialization path.


1. Aggregate Report Controller — Leak in NewController#

pkg/controllers/report/aggregate/controller.go spawns an unbounded background cleanup goroutine directly inside NewController. The goroutine runs a bare for {} loop with a 10-second time.Sleep, carries no context, and never observes cancellation. Every call to NewController—including each leader-election restart and every test setup—leaks one goroutine permanently.

Correct pattern: The Run(ctx, workers) method already manages worker goroutines properly via group.StartWithContext(ctx, ...). The cleanup loop should be moved there and driven by wait.UntilWithContext, so it terminates when ctx is cancelled.


2. Resource-Report Watcher — Context Leak in startWatcher#

pkg/controllers/report/resource/controller.go's startWatcher previously passed context.Background() to the Kubernetes Watch call and context.TODO() to watchTools.NewRetryWatcherWithContext, ignoring the ctx parameter the method already received. The watchers therefore could not be cancelled when the controller shut down, leaving hanging connections and leaked goroutines.

PR #16087 fixed this by replacing both hardcoded contexts with ctx at lines 341 and 347, ensuring watch connections terminate with the controller lifecycle.


3. Circuit Breaker Initialization — Missing Retry / Goroutine Leak on Failure#

The circuit breaker package exposes a global ReportsBreaker via GetReportsBreaker() / SetReportsBreaker() backed by atomic.Value . The breaker's open predicate is driven by a counter that performs a list+watch against the ephemeral reports API at startup via StartAdmissionReportsCounter / StartBackgroundReportsCounter.

Before PR #13641, a failure in the counter startup caused an os.Exit(1), taking down the entire controller and blocking cluster admission. The PR replaced this with:

  1. Temporary permissive breaker — on failure, SetReportsBreaker is called immediately with a breaker whose open func always returns false (allows all), so the controller starts normally.
  2. Background retry goroutine — a goroutine retries the counter startup every 2 seconds; on success it calls SetReportsBreaker again with the real counter-backed breaker.

Each controller calls the appropriate counter startup function for its report type: cmd/kyverno/main.go and cmd/reports-controller/main.go use StartAdmissionReportsCounter (watching audit.kyverno.io/source==admission reports), while cmd/background-controller/main.go uses StartBackgroundReportsCounter (watching source==background-scan reports). This ensures each controller's circuit breaker monitors the ephemeral reports it actually creates.

For example, cmd/background-controller/main.go:

ephrs, err := breaker.StartBackgroundReportsCounter(signalCtx, setup.MetadataClient)
if err != nil {
    go func() {
        for {
            ephrs, err := breaker.StartBackgroundReportsCounter(signalCtx, setup.MetadataClient)
            if err != nil {
                setup.Logger.Error(err, "failed to start background scan reports watcher, retrying...")
                time.Sleep(2 * time.Second)
                continue
            }
            // ... set real breaker with counter
        }
    }()
}

The resource counter goroutine itself is driven by the RetryWatcher's result channel; it exits naturally when the channel closes, so it does not need explicit context wiring.


Key Patterns#

Anti-patternCorrect pattern
Spawn goroutines in NewController with no lifecycleMove to Run(ctx) via wait.UntilWithContext
context.Background() / context.TODO() in watcher setupPropagate parent ctx through all watch calls
os.Exit(1) on init failure for optional dependenciesSet temporary permissive state; retry in background goroutine

Primary Source Files#

FileRole
pkg/controllers/report/aggregate/controller.goAggregate report controller with the leaking cleanup goroutine
pkg/controllers/report/resource/controller.goResource watcher with the fixed context propagation
pkg/breaker/breaker.goGlobal ReportsBreaker + Breaker interface
pkg/breaker/resource_counter.goStartAdmissionReportsCounter / StartBackgroundReportsCounter
Documents
Admission Policy CEL Evaluation
API Call Execution
API Call Response Size Enforcement
Background Controller Trigger Validation
Background Controller UpdateRequest Processing
Background Mutation Engine
Background Scan Report Reconciliation
CEL Context Injection
CEL Policy Exception Handling
CLI Policy Result Processing
CLI Policy Testing
CLI Resource Resolution
CLI Worker Pool Management
Concurrency Safety
Engine Context Propagation
Generate Policy UpdateRequest Lifecycle
GeneratingPolicy Downstream Cleanup
GeneratingPolicy Synchronization and Reconciliation
Git URL Parsing
Image Verification CEL Path
ImageValidatingPolicy Webhook Architecture
JMESPath Type Safety
JSON Patch Mutation
MutatingPolicy CEL Engine Namespace Resolution
MutatingPolicy Resource Targeting
Namespaced Image Validating Policy
NamespaceSelector Policy Enforcement
OCI Referrers API Fallback
Policy Controller Reconciliation
Projected Service Account Token
Prometheus Metrics Integration
Registry Authentication
Report Controller Goroutine Lifecycle
Sigstore & TUF Integration
Strategic Merge Patch
TTL Controller Lifecycle
ValidatingPolicy Autogen
ValidatingPolicy Engine
ValidatingPolicy Status Management
Variable Substitution
Webhook Generation
Webhook Lifecycle Management
Webhook Selector Grouping