Skip to content

Commit 2cdc80b

Browse files
committed
fix: harden v6 client lifecycle
1 parent 13f169e commit 2cdc80b

7 files changed

Lines changed: 268 additions & 20 deletions

File tree

modern_cache.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
package agollo
1616

1717
import (
18+
"context"
1819
"encoding/base64"
1920
"encoding/json"
2021
"errors"
@@ -93,15 +94,21 @@ func (c *ApolloClient) persistLocalSnapshot(snapshot ConfigSnapshot) error {
9394
return nil
9495
}
9596

96-
func (c *ApolloClient) loadLocalSnapshot(key ConfigKey) (ConfigSnapshot, error) {
97+
func (c *ApolloClient) loadLocalSnapshot(ctx context.Context, key ConfigKey) (ConfigSnapshot, error) {
9798
for _, file := range []string{c.cacheFile(key), c.legacyCacheFile(key)} {
99+
if err := ctx.Err(); err != nil {
100+
return ConfigSnapshot{}, err
101+
}
98102
body, err := os.ReadFile(file)
99103
if err != nil {
100104
if errors.Is(err, os.ErrNotExist) {
101105
continue
102106
}
103107
return ConfigSnapshot{}, fmt.Errorf("agollo: read local cache: %w", err)
104108
}
109+
if err := ctx.Err(); err != nil {
110+
return ConfigSnapshot{}, err
111+
}
105112
if snapshot, err := decodeDiskSnapshot(key, body); err == nil {
106113
return snapshot, nil
107114
}

modern_client.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,9 @@ func (c *ApolloClient) Load(ctx context.Context, namespaces ...string) error {
286286
}
287287

288288
func (c *ApolloClient) getState(ctx context.Context, appID, namespace string, format ConfigFileFormat) (*modernConfig, error) {
289+
if ctx == nil {
290+
return nil, errors.New("agollo: context is nil")
291+
}
289292
appID = strings.TrimSpace(appID)
290293
namespace = strings.TrimSpace(namespace)
291294
if appID == "" {
@@ -374,6 +377,31 @@ func (c *ApolloClient) goBackground(run func(context.Context)) {
374377
}()
375378
}
376379

380+
func (c *ApolloClient) beginSubscription() bool {
381+
c.mu.Lock()
382+
defer c.mu.Unlock()
383+
if c.closed {
384+
return false
385+
}
386+
c.wg.Add(1)
387+
return true
388+
}
389+
390+
// loadContext is canceled when either the operation context or the client
391+
// lifecycle context ends. The watcher exits as soon as the returned cancel
392+
// function is called, so individual loads do not leave goroutines behind.
393+
func (c *ApolloClient) loadContext(ctx context.Context) (context.Context, context.CancelFunc) {
394+
loadContext, cancel := context.WithCancel(ctx)
395+
go func() {
396+
select {
397+
case <-c.ctx.Done():
398+
cancel()
399+
case <-loadContext.Done():
400+
}
401+
}()
402+
return loadContext, cancel
403+
}
404+
377405
func normalizeURLs(urls []string) []string {
378406
result := make([]string, 0, len(urls))
379407
for _, rawURL := range urls {

modern_client_test.go

Lines changed: 187 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@ package agollo
1717
import (
1818
"context"
1919
"encoding/json"
20+
"errors"
2021
"net/http"
2122
"net/http/httptest"
23+
"os"
2224
"regexp"
2325
"strings"
2426
"sync"
@@ -465,6 +467,40 @@ func TestApolloClientCloseRejectsNewStateAndSubscriptions(t *testing.T) {
465467
}
466468
}
467469

470+
func TestApolloClientCloseWaitsForConfigSubscription(t *testing.T) {
471+
client := newTestClient(t, "http://127.0.0.1", ClientOptions{AppID: "sample"})
472+
state := newModernConfig(client, ConfigKey{AppID: "sample", Cluster: "default", Namespace: "application", Format: ConfigFileFormatProperties})
473+
client.mu.Lock()
474+
client.states[state.key] = state
475+
client.mu.Unlock()
476+
477+
started := make(chan struct{})
478+
release := make(chan struct{})
479+
state.Subscribe(func(ConfigChangeEvent) {
480+
close(started)
481+
<-release
482+
})
483+
state.publish(ConfigSnapshot{Values: map[string]interface{}{"key": "value"}, Source: ConfigSourceRemote})
484+
<-started
485+
486+
closed := make(chan error, 1)
487+
go func() { closed <- client.Close() }()
488+
select {
489+
case err := <-closed:
490+
t.Fatalf("Close() returned before listener finished: %v", err)
491+
case <-time.After(50 * time.Millisecond):
492+
}
493+
close(release)
494+
select {
495+
case err := <-closed:
496+
if err != nil {
497+
t.Fatalf("Close() error = %v", err)
498+
}
499+
case <-time.After(time.Second):
500+
t.Fatal("Close() did not wait for listener completion")
501+
}
502+
}
503+
468504
func TestDecodeDiskSnapshotRejectsDifferentCluster(t *testing.T) {
469505
t.Parallel()
470506
body, err := json.Marshal(diskSnapshot{
@@ -590,6 +626,117 @@ func TestApolloClientOfflineLoadsConfigMapWithoutCacheDirectory(t *testing.T) {
590626
}
591627
}
592628

629+
func TestApolloClientOfflineConfigMapDoesNotReadWorkingDirectoryCache(t *testing.T) {
630+
workingDirectory, err := os.Getwd()
631+
if err != nil {
632+
t.Fatalf("Getwd() error = %v", err)
633+
}
634+
temporaryDirectory := t.TempDir()
635+
if err := os.Chdir(temporaryDirectory); err != nil {
636+
t.Fatalf("Chdir() error = %v", err)
637+
}
638+
t.Cleanup(func() { _ = os.Chdir(workingDirectory) })
639+
if err := os.WriteFile("sample-application.json", []byte(`{"appId":"sample","cluster":"default","namespaceName":"application","configurations":{"key":"disk"}}`), 0o600); err != nil {
640+
t.Fatalf("WriteFile() error = %v", err)
641+
}
642+
643+
store := &memoryConfigMapStore{snapshot: ConfigSnapshot{
644+
Key: ConfigKey{AppID: "sample", Cluster: "default", Namespace: "application", Format: ConfigFileFormatProperties},
645+
Values: map[string]interface{}{"key": "configmap"},
646+
}}
647+
client, err := NewClient(context.Background(), ClientOptions{AppID: "sample", ConfigMapStore: store, Offline: true})
648+
if err != nil {
649+
t.Fatalf("NewClient() error = %v", err)
650+
}
651+
defer client.Close()
652+
config, err := client.Config(context.Background(), "application")
653+
if err != nil {
654+
t.Fatalf("Config() error = %v", err)
655+
}
656+
if got := config.String("key", ""); got != "configmap" || config.Source() != ConfigSourceConfigMap {
657+
t.Fatalf("offline ConfigMap value = %q from %s", got, config.Source())
658+
}
659+
}
660+
661+
func TestApolloClientLoadHonorsOperationAndLifecycleContexts(t *testing.T) {
662+
t.Run("operation context cancels ConfigMap load", func(t *testing.T) {
663+
store := &blockingConfigMapStore{started: make(chan struct{}, 1)}
664+
client, err := NewClient(context.Background(), ClientOptions{AppID: "sample", ConfigMapStore: store, Offline: true})
665+
if err != nil {
666+
t.Fatalf("NewClient() error = %v", err)
667+
}
668+
defer client.Close()
669+
ctx, cancel := context.WithCancel(context.Background())
670+
result := make(chan error, 1)
671+
go func() {
672+
_, err := client.Config(ctx, "application")
673+
result <- err
674+
}()
675+
<-store.started
676+
cancel()
677+
select {
678+
case err := <-result:
679+
if !errors.Is(err, context.Canceled) {
680+
t.Fatalf("Config() error = %v, want context cancellation", err)
681+
}
682+
case <-time.After(time.Second):
683+
t.Fatal("ConfigMap load did not observe operation cancellation")
684+
}
685+
})
686+
687+
t.Run("client lifecycle cancels remote load", func(t *testing.T) {
688+
started := make(chan struct{}, 1)
689+
server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, request *http.Request) {
690+
select {
691+
case started <- struct{}{}:
692+
default:
693+
}
694+
<-request.Context().Done()
695+
}))
696+
defer server.Close()
697+
clientContext, cancelClient := context.WithCancel(context.Background())
698+
client, err := NewClient(clientContext, ClientOptions{AppID: "sample", ConfigServices: []string{server.URL}, DisableLongPolling: true})
699+
if err != nil {
700+
t.Fatalf("NewClient() error = %v", err)
701+
}
702+
defer client.Close()
703+
result := make(chan error, 1)
704+
go func() {
705+
_, err := client.Config(context.Background(), "application")
706+
result <- err
707+
}()
708+
<-started
709+
cancelClient()
710+
select {
711+
case err := <-result:
712+
if !errors.Is(err, context.Canceled) {
713+
t.Fatalf("Config() error = %v, want context cancellation", err)
714+
}
715+
case <-time.After(time.Second):
716+
t.Fatal("remote load did not observe client cancellation")
717+
}
718+
})
719+
}
720+
721+
func TestApolloClientIntSliceReadsNativeIntSlice(t *testing.T) {
722+
store := &memoryConfigMapStore{snapshot: ConfigSnapshot{
723+
Key: ConfigKey{AppID: "sample", Cluster: "default", Namespace: "application", Format: ConfigFileFormatProperties},
724+
Values: map[string]interface{}{"ports": []int{8080, 9090}},
725+
}}
726+
client, err := NewClient(context.Background(), ClientOptions{AppID: "sample", ConfigMapStore: store, Offline: true})
727+
if err != nil {
728+
t.Fatalf("NewClient() error = %v", err)
729+
}
730+
defer client.Close()
731+
config, err := client.Config(context.Background(), "application")
732+
if err != nil {
733+
t.Fatalf("Config() error = %v", err)
734+
}
735+
if got := config.IntSlice("ports", nil); len(got) != 2 || got[0] != 8080 || got[1] != 9090 {
736+
t.Fatalf("IntSlice(ports) = %v", got)
737+
}
738+
}
739+
593740
func TestApolloClientDiscoversConfigServiceFromMetaServer(t *testing.T) {
594741
t.Parallel()
595742
var server *httptest.Server
@@ -651,8 +798,29 @@ func TestApolloClientLongPollBuildsDataCenterAndRefreshes(t *testing.T) {
651798
}
652799
}))
653800
defer server.Close()
801+
wrongServer := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, request *http.Request) {
802+
t.Fatalf("selector was bypassed for %s", request.URL.Path)
803+
}))
804+
defer wrongServer.Close()
654805

655-
client := newTestClient(t, server.URL, ClientOptions{AppID: "sample", DataCenter: "sh", ClientIP: "127.0.0.2"})
806+
var selectorCalls atomic.Int32
807+
client, err := NewClient(context.Background(), ClientOptions{
808+
AppID: "sample",
809+
DataCenter: "sh",
810+
ClientIP: "127.0.0.2",
811+
ConfigServices: []string{wrongServer.URL, server.URL},
812+
DisableLongPolling: true,
813+
ConfigServiceSelector: func(appID string, services []string) (string, error) {
814+
if appID != "sample" || len(services) != 2 {
815+
t.Fatalf("selector input = %q, %v", appID, services)
816+
}
817+
selectorCalls.Add(1)
818+
return server.URL, nil
819+
},
820+
})
821+
if err != nil {
822+
t.Fatalf("NewClient() error = %v", err)
823+
}
656824
defer client.Close()
657825
config, err := client.Config(context.Background(), "application")
658826
if err != nil {
@@ -665,6 +833,9 @@ func TestApolloClientLongPollBuildsDataCenterAndRefreshes(t *testing.T) {
665833
if got := config.String("key", ""); got != "after" {
666834
t.Fatalf("long-poll refreshed value = %q", got)
667835
}
836+
if got := selectorCalls.Load(); got != 3 {
837+
t.Fatalf("selector calls = %d, want initial fetch, long-poll, and refresh selection", got)
838+
}
668839
}
669840

670841
func TestApolloClientCloseCancelsLongPoll(t *testing.T) {
@@ -761,6 +932,21 @@ type memoryConfigMapStore struct {
761932
saved chan ConfigSnapshot
762933
}
763934

935+
type blockingConfigMapStore struct {
936+
started chan struct{}
937+
}
938+
939+
func (s *blockingConfigMapStore) Load(ctx context.Context, _ ConfigKey) (ConfigSnapshot, error) {
940+
select {
941+
case s.started <- struct{}{}:
942+
default:
943+
}
944+
<-ctx.Done()
945+
return ConfigSnapshot{}, ctx.Err()
946+
}
947+
948+
func (s *blockingConfigMapStore) Save(context.Context, ConfigSnapshot) error { return nil }
949+
764950
func (s *memoryConfigMapStore) Load(ctx context.Context, key ConfigKey) (ConfigSnapshot, error) {
765951
s.mu.Lock()
766952
defer s.mu.Unlock()

modern_config.go

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -187,9 +187,13 @@ func (c *modernConfig) Subscribe(listener ConfigChangeHandler, options ...Subscr
187187
c.listenerMu.Unlock()
188188
return func() {}
189189
}
190+
if !c.client.beginSubscription() {
191+
c.listenerMu.Unlock()
192+
return func() {}
193+
}
190194
id := c.nextListener
191195
c.nextListener++
192-
subscription := newConfigSubscription(listener, settings, c.client.options.listenerQueueSize, &c.client.monitor)
196+
subscription := newConfigSubscription(listener, settings, c.client.options.listenerQueueSize, &c.client.monitor, &c.client.wg)
193197
c.listeners[id] = subscription
194198
c.listenerMu.Unlock()
195199

@@ -367,9 +371,13 @@ func (c *modernConfigFile) Subscribe(listener ConfigFileChangeHandler) func() {
367371
config.listenerMu.Unlock()
368372
return func() {}
369373
}
374+
if !config.client.beginSubscription() {
375+
config.listenerMu.Unlock()
376+
return func() {}
377+
}
370378
id := config.nextListener
371379
config.nextListener++
372-
subscription := newFileSubscription(listener, config.client.options.listenerQueueSize, &config.client.monitor)
380+
subscription := newFileSubscription(listener, config.client.options.listenerQueueSize, &config.client.monitor, &config.client.wg)
373381
config.fileListeners[id] = subscription
374382
config.listenerMu.Unlock()
375383
return func() {
@@ -425,10 +433,11 @@ type configSubscription struct {
425433
done chan struct{}
426434
once sync.Once
427435
monitor *modernMonitor
436+
wg *sync.WaitGroup
428437
}
429438

430-
func newConfigSubscription(listener ConfigChangeHandler, options subscribeOptions, size int, monitor *modernMonitor) *configSubscription {
431-
subscription := &configSubscription{listener: listener, options: options, queue: make(chan ConfigChangeEvent, size), done: make(chan struct{}), monitor: monitor}
439+
func newConfigSubscription(listener ConfigChangeHandler, options subscribeOptions, size int, monitor *modernMonitor, wg *sync.WaitGroup) *configSubscription {
440+
subscription := &configSubscription{listener: listener, options: options, queue: make(chan ConfigChangeEvent, size), done: make(chan struct{}), monitor: monitor, wg: wg}
432441
go subscription.run()
433442
return subscription
434443
}
@@ -458,6 +467,7 @@ func (s *configSubscription) offer(event ConfigChangeEvent) {
458467
}
459468

460469
func (s *configSubscription) run() {
470+
defer s.wg.Done()
461471
for {
462472
select {
463473
case <-s.done:
@@ -479,10 +489,11 @@ type fileSubscription struct {
479489
done chan struct{}
480490
once sync.Once
481491
monitor *modernMonitor
492+
wg *sync.WaitGroup
482493
}
483494

484-
func newFileSubscription(listener ConfigFileChangeHandler, size int, monitor *modernMonitor) *fileSubscription {
485-
subscription := &fileSubscription{listener: listener, queue: make(chan ConfigFileChangeEvent, size), done: make(chan struct{}), monitor: monitor}
495+
func newFileSubscription(listener ConfigFileChangeHandler, size int, monitor *modernMonitor, wg *sync.WaitGroup) *fileSubscription {
496+
subscription := &fileSubscription{listener: listener, queue: make(chan ConfigFileChangeEvent, size), done: make(chan struct{}), monitor: monitor, wg: wg}
486497
go subscription.run()
487498
return subscription
488499
}
@@ -510,6 +521,7 @@ func (s *fileSubscription) offer(event ConfigFileChangeEvent) {
510521
}
511522

512523
func (s *fileSubscription) run() {
524+
defer s.wg.Done()
513525
for {
514526
select {
515527
case <-s.done:

modern_poller.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,14 @@ func (p *appPoller) poll() error {
8080
if err != nil {
8181
return err
8282
}
83-
serviceURL := p.client.nextConfigService(p.appID)
84-
if serviceURL == "" || len(services) == 0 {
83+
if len(services) == 0 {
84+
return errors.New("agollo: no Config Service for long poll")
85+
}
86+
serviceURL, err := p.client.selectConfigService(p.appID, services)
87+
if err != nil {
88+
return err
89+
}
90+
if serviceURL == "" {
8591
return errors.New("agollo: no Config Service for long poll")
8692
}
8793
endpoint, err := p.notificationsURL(serviceURL, states)

0 commit comments

Comments
 (0)