{"record":{"id":"96b21ac295f9b359","repo":"grpc/grpc-go","slug":"balancergroup-already-closed","errorCode":null,"errorMessage":"balancergroup: already closed","messagePattern":"balancergroup: already closed","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"internal/balancergroup/balancergroup.go","lineNumber":284,"sourceCode":"\n// AddWithClientConn adds a balancer with the given id to the group. The\n// balancer is built with a balancer builder registered with balancerName. The\n// given ClientConn is passed to the newly built balancer instead of the\n// one passed to balancergroup.New().\n//\n// TODO: Get rid of the existing Add() API and replace it with this.\nfunc (bg *BalancerGroup) AddWithClientConn(id, balancerName string, cc balancer.ClientConn) error {\n\tbg.logger.Infof(\"Adding child policy of type %q for child %q\", balancerName, id)\n\tbuilder := balancer.Get(balancerName)\n\tif builder == nil {\n\t\treturn fmt.Errorf(\"balancergroup: unregistered balancer name %q\", balancerName)\n\t}\n\n\t// Store data in static map, and then check to see if bg is started.\n\tbg.outgoingMu.Lock()\n\tdefer bg.outgoingMu.Unlock()\n\tif bg.outgoingClosed {\n\t\treturn fmt.Errorf(\"balancergroup: already closed\")\n\t}\n\tvar sbc *subBalancerWrapper\n\t// Skip searching the cache if disabled.\n\tif bg.deletedBalancerCache != nil {\n\t\tif old, ok := bg.deletedBalancerCache.Remove(id); ok {\n\t\t\tif bg.logger.V(2) {\n\t\t\t\tbg.logger.Infof(\"Removing and reusing child policy of type %q for child %q from the balancer cache\", balancerName, id)\n\t\t\t\tbg.logger.Infof(\"Number of items remaining in the balancer cache: %d\", bg.deletedBalancerCache.Len())\n\t\t\t}\n\n\t\t\tsbc, _ = old.(*subBalancerWrapper)\n\t\t\tif sbc != nil && sbc.builder != builder {\n\t\t\t\t// If the sub-balancer in cache was built with a different\n\t\t\t\t// balancer builder, don't use it, cleanup this old-balancer,\n\t\t\t\t// and behave as sub-balancer is not found in cache.\n\t\t\t\t//\n\t\t\t\t// NOTE that this will also drop the cached addresses for this\n\t\t\t\t// sub-balancer, which seems to be reasonable.","sourceCodeStart":266,"sourceCodeEnd":302,"githubUrl":"https://github.com/grpc/grpc-go/blob/0c51461d27177d997e14c642fe18c11668fc09a3/internal/balancergroup/balancergroup.go#L266-L302","documentation":"Returned by BalancerGroup.AddWithClientConn when bg.outgoingClosed is true (balancergroup.go:283-285). Once Close() sets outgoingClosed (balancergroup.go:554), the BalancerGroup refuses to accept any new sub-balancers. This is a lifecycle guard to prevent adding children to a shut-down group.","triggerScenarios":"Calling bg.Add() or bg.AddWithClientConn() after bg.Close() has been invoked. The Close() method sets outgoingClosed = true under outgoingMu (balancergroup.go:551-555), and AddWithClientConn checks this flag right after acquiring the same lock.","commonSituations":"A lifecycle race in the parent balancer where the group is closed (e.g., the parent LB policy is shutting down due to channel close) but a late resolver config update triggers a new Add. Common in priority or cluster resolver balancers during teardown.","solutions":["Fix the lifecycle ordering so Close() is not called until all Add() operations are complete","Guard against this error in concurrent code by checking the return value and treating it as expected during shutdown","Synchronize the parent balancer's shutdown with in-flight config updates"],"exampleFix":"// before: race between close and add\n// goroutine 1:\nbg.Close()\n// goroutine 2 (late config update):\nbg.Add(\"child-1\", builder) // error: already closed\n\n// after: synchronize lifecycle\nvar wg sync.WaitGroup\nwg.Add(1)\n// goroutine 2 completes adds before close\ngo func() { defer wg.Done(); bg.Add(\"child-1\", builder) }()\nwg.Wait()\nbg.Close()","handlingStrategy":"validation","validationCode":null,"typeGuard":null,"tryCatchPattern":"// Treat 'already closed' as expected during shutdown\nif err := bg.AddWithClientConn(id, name, cc); err != nil {\n    if strings.Contains(err.Error(), \"already closed\") {\n        return // expected during teardown\n    }\n    return err\n}","preventionTips":["Synchronize Close() with Add/AddWithClientConn calls using a mutex or channel","In concurrent balancer implementations, treat 'already closed' as benign during teardown","Establish a clear shutdown sequence: stop accepting config updates before calling Close()"],"tags":["grpc","balancer","balancergroup","lifecycle","concurrency"],"backgroundTag":null,"analyzedSha":"0c51461d27177d997e14c642fe18c11668fc09a3","analyzedAt":"2026-08-11T14:49:15.055Z","contentChangedAt":null,"schemaVersion":2},"datasetVersion":"2026-09-14T00:17:10.932Z"}