Skip to content

Commit ea3b7c2

Browse files
authored
otelcol: synchronize Run and Shutdown lifecycle (open-telemetry#14989)
Right now `Shutdown()` returns before `Run()` is done cleaning up, which means callers that treat "Shutdown returned" as "everything is freed" can run into goroutine leaks and use-after-close problems. This is the root of open-telemetry#4947. The fix adds a `done` channel that `Run()` closes via `defer` when it finishes, and an `atomic.Bool` so `Shutdown()` knows whether `Run()` was called. If it was, `Shutdown()` blocks on `<-col.done` until `Run()` is fully done. This sidesteps the WaitGroup idea from open-telemetry#8811 that deadlocks when `Shutdown` happens before `Run`. I also added a `CompareAndSwap` guard on the `Run()` entry so calling it twice returns an error instead of panicking on the double-close of the done channel, and the early-return path (when Shutdown was called before Run) now properly shuts down the config provider so we don't leak confmap resources. Tests cover shutdown-before-run, shutdown-during-run, double-run, and the blocking guarantee. Fixes open-telemetry#4947 --------- Signed-off-by: Rajneesh Chaudhary <rajneeshrehsaan48@gmail.com>
1 parent 77b01ef commit ea3b7c2

3 files changed

Lines changed: 141 additions & 2 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# cspell:ignore lifecycles
2+
# Use this changelog template to create an entry for release notes.
3+
4+
# One of 'breaking', 'deprecation', 'new_component', 'enhancement', 'bug_fix'
5+
change_type: bug_fix
6+
7+
# The name of the component, or a single word describing the area of concern, (e.g. receiver/otlp)
8+
component: pkg/otelcol
9+
10+
# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`).
11+
note: Synchronize Collector Run and Shutdown lifecycles so that Shutdown blocks until Run completes all cleanup.
12+
13+
# One or more tracking issues or pull requests related to the change
14+
issues: [4947]
15+
16+
# (Optional) One or more lines of additional information to render under the primary note.
17+
# These lines will be padded with 2 spaces and then inserted directly into the document.
18+
# Use pipe (|) for multiline entries.
19+
subtext: |
20+
Shutdown now blocks until Run finishes cleanup, matching http.Server semantics.
21+
If Shutdown is called before Run, the next Run call returns nil after cleaning up
22+
the config provider.
23+
24+
# Optional: The change log or logs in which this entry should be included.
25+
# e.g. '[user]' or '[user, api]'
26+
# Include 'user' if the change is relevant to end users.
27+
# Include 'api' if there is a change to a library API.
28+
# Default: '[user]'
29+
change_logs: [user, api]

otelcol/collector.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,9 @@ type Collector struct {
111111
shutdownChan chan struct{}
112112
shutdownOnce sync.Once
113113

114+
// wg is used by Shutdown to wait for Run to complete all cleanup.
115+
wg sync.WaitGroup
116+
114117
// signalsChannel is used to receive termination signals from the OS.
115118
signalsChannel chan os.Signal
116119
// asyncErrorChannel is used to signal a fatal error from any component.
@@ -156,10 +159,12 @@ func (col *Collector) GetState() State {
156159
}
157160

158161
// Shutdown shuts down the collector server.
162+
// If Run has been called, Shutdown blocks until Run completes all cleanup.
159163
func (col *Collector) Shutdown() {
160164
col.shutdownOnce.Do(func() {
161165
close(col.shutdownChan)
162166
})
167+
col.wg.Wait()
163168
}
164169

165170
func buildModuleInfo(m map[component.Type]string) map[component.Type]service.ModuleInfo {
@@ -324,7 +329,22 @@ func newFallbackLogger(options []zap.Option) (*zap.Logger, error) {
324329
// Run starts the collector according to the given configuration, and waits for it to complete.
325330
// Consecutive calls to Run are not allowed, Run shouldn't be called once a collector is shut down.
326331
// Sets up the control logic for config reloading and shutdown.
332+
// If Shutdown was called before Run, Run returns nil after cleaning up resources.
327333
func (col *Collector) Run(ctx context.Context) error {
334+
col.wg.Add(1)
335+
defer col.wg.Done()
336+
337+
// If Shutdown was already called, return immediately without starting the service.
338+
select {
339+
case <-col.shutdownChan:
340+
col.setCollectorState(StateClosed)
341+
if err := col.configProvider.Shutdown(ctx); err != nil {
342+
return fmt.Errorf("failed to shutdown config provider: %w", err)
343+
}
344+
return nil
345+
default:
346+
}
347+
328348
// setupConfigurationComponents is the "main" function responsible for startup
329349
if err := col.setupConfigurationComponents(ctx); err != nil {
330350
col.setCollectorState(StateClosed)

otelcol/collector_test.go

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ func TestCollectorStateAfterConfigChange(t *testing.T) {
145145
watcher(&confmap.ChangeEvent{})
146146
unblock = <-shutdownRequests
147147
assert.Equal(t, StateClosing, col.GetState())
148-
col.Shutdown()
148+
go col.Shutdown() // Shutdown now blocks until Run completes, so signal asynchronously.
149149
close(unblock)
150150

151151
// After the config reload, the final shutdown should occur.
@@ -410,6 +410,10 @@ func TestCollectorRun(t *testing.T) {
410410

411411
wg := startCollector(context.Background(), t, col)
412412

413+
assert.Eventually(t, func() bool {
414+
return StateRunning == col.GetState()
415+
}, 2*time.Second, 200*time.Millisecond)
416+
413417
col.Shutdown()
414418
wg.Wait()
415419
assert.Equal(t, StateClosed, col.GetState())
@@ -429,10 +433,75 @@ func TestCollectorRun_AfterShutdown(t *testing.T) {
429433
// Calling shutdown before collector is running should cause it to return quickly
430434
require.NotPanics(t, func() { col.Shutdown() })
431435

436+
// Run after Shutdown should return nil without starting the service.
437+
err = col.Run(context.Background())
438+
require.NoError(t, err)
439+
assert.Equal(t, StateClosed, col.GetState())
440+
}
441+
442+
func TestCollectorRun_AfterShutdown_ConfigProviderShutdownError(t *testing.T) {
443+
set := CollectorSettings{
444+
BuildInfo: component.NewDefaultBuildInfo(),
445+
Factories: nopFactories,
446+
ConfigProviderSettings: newDefaultConfigProviderSettings(t, []string{filepath.Join("testdata", "otelcol-nop.yaml")}),
447+
}
448+
col, err := NewCollector(set)
449+
require.NoError(t, err)
450+
451+
col.Shutdown()
452+
453+
wantErr := errors.New("provider shutdown failed")
454+
resolver, resolverErr := confmap.NewResolver(confmap.ResolverSettings{
455+
URIs: []string{"err:config"},
456+
ProviderFactories: []confmap.ProviderFactory{
457+
confmap.NewProviderFactory(func(_ confmap.ProviderSettings) confmap.Provider {
458+
return &errShutdownProvider{err: wantErr}
459+
}),
460+
},
461+
})
462+
require.NoError(t, resolverErr)
463+
col.configProvider = &ConfigProvider{mapResolver: resolver}
464+
465+
runErr := col.Run(context.Background())
466+
require.ErrorContains(t, runErr, "failed to shutdown config provider")
467+
require.ErrorIs(t, runErr, wantErr)
468+
assert.Equal(t, StateClosed, col.GetState())
469+
}
470+
471+
func TestShutdownBlocksUntilRunCompletes(t *testing.T) {
472+
set := CollectorSettings{
473+
BuildInfo: component.NewDefaultBuildInfo(),
474+
Factories: nopFactories,
475+
ConfigProviderSettings: newDefaultConfigProviderSettings(t, []string{filepath.Join("testdata", "otelcol-nop.yaml")}),
476+
}
477+
col, err := NewCollector(set)
478+
require.NoError(t, err)
479+
480+
// Start the collector in a goroutine.
432481
wg := startCollector(context.Background(), t, col)
433482

483+
assert.Eventually(t, func() bool {
484+
return StateRunning == col.GetState()
485+
}, 2*time.Second, 200*time.Millisecond)
486+
487+
// Record whether Run has finished by the time Shutdown returns.
488+
runFinished := make(chan struct{})
489+
go func() {
490+
wg.Wait()
491+
close(runFinished)
492+
}()
493+
494+
// Shutdown should block until Run completes.
434495
col.Shutdown()
435-
wg.Wait()
496+
497+
// After Shutdown returns, Run must have finished.
498+
select {
499+
case <-runFinished:
500+
// expected: Run completed before or at the same time Shutdown returned.
501+
case <-time.After(5 * time.Second):
502+
t.Fatal("Run did not complete after Shutdown returned")
503+
}
504+
436505
assert.Equal(t, StateClosed, col.GetState())
437506
}
438507

@@ -603,6 +672,22 @@ func (*failureProvider) Shutdown(context.Context) error {
603672
return nil
604673
}
605674

675+
type errShutdownProvider struct {
676+
err error
677+
}
678+
679+
func (p *errShutdownProvider) Retrieve(context.Context, string, confmap.WatcherFunc) (*confmap.Retrieved, error) {
680+
return confmap.NewRetrieved(nil)
681+
}
682+
683+
func (p *errShutdownProvider) Scheme() string {
684+
return "err"
685+
}
686+
687+
func (p *errShutdownProvider) Shutdown(context.Context) error {
688+
return p.err
689+
}
690+
606691
type fakeProvider struct {
607692
scheme string
608693
ret func(ctx context.Context, uri string, watcher confmap.WatcherFunc) (*confmap.Retrieved, error)
@@ -708,6 +793,11 @@ func TestProviderAndConverterModules(t *testing.T) {
708793
require.NoError(t, err)
709794
wg := startCollector(context.Background(), t, col)
710795
require.NoError(t, err)
796+
797+
assert.Eventually(t, func() bool {
798+
return StateRunning == col.GetState()
799+
}, 2*time.Second, 200*time.Millisecond)
800+
711801
providerModules := map[string]string{
712802
"nop": "go.opentelemetry.io/collector/confmap/provider/testprovider v1.2.3",
713803
}

0 commit comments

Comments
 (0)