Repository: avenga/couper
Pull request #564 (2022-08-25 09:01:16)

Fix go routines leaks
Transport and use the "main" context to avoid leaks after reload (via `-watch`) of config.

---
<details>
    <summary>Reviewer checklist</summary>
    <ul>
        <li>Read PR description: a summary about the changes is required</li>
        <li>Changelog updated</li>
        <li>Documentation: docs/{Reference, Cli, ...}, Docker and cli help/usage</li>
        <li>Pulled branch, manually tested</li>
        <li>Verified requirements are met</li>
        <li>Reviewed the code</li>
        <li>Reviewed the related tests</li>
    </ul>
</details>



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e30ab47a..d078e33a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -21,6 +21,7 @@ Unreleased changes are available as `avenga/couper:edge` container.
   * Basic Auth client authentication with OAuth2 (client ID and secret must be URL encoded) ([#537](https://github.com/avenga/couper/pull/537))
   * Config validation, e.g. label-uniqueness checks ([#563](https://github.com/avenga/couper/pull/563))
   * [OIDC](https://docs.couper.io/configuration/block/oidc) not using referenced backends, if only specific backends (`configuration_backend`, `jwks_uri_backend`, `token_backend`, `userinfo_backend`) were configured ([#570](https://github.com/avenga/couper/pull/570))
+  * [OIDC](https://docs.couper.io/configuration/block/oidc) configuration related go-routine leak after retrieving a new payload due to config ttl ([#564](https://github.com/avenga/couper/pull/564))
 
 * **Removed**
   * Endpoint path normalization to better match OpenAPI behavior ([#526](https://github.com/avenga/couper/pull/526))
diff --git a/accesscontrol/jwk/jwks.go b/accesscontrol/jwk/jwks.go
index 6b0fd1e0..e0e75fc1 100644
--- a/accesscontrol/jwk/jwks.go
+++ b/accesscontrol/jwk/jwks.go
@@ -1,6 +1,7 @@
 package jwk
 
 import (
+	"context"
 	"encoding/json"
 	"fmt"
 	"net/http"
@@ -30,7 +31,7 @@ type JWKS struct {
 	syncedJSON *jsn.SyncedJSON
 }
 
-func NewJWKS(uri string, ttl string, maxStale string, transport http.RoundTripper) (*JWKS, error) {
+func NewJWKS(ctx context.Context, uri string, ttl string, maxStale string, transport http.RoundTripper) (*JWKS, error) {
 	timetolive, err := config.ParseDuration("jwks_ttl", ttl, time.Hour)
 	if err != nil {
 		return nil, err
@@ -47,7 +48,7 @@ func NewJWKS(uri string, ttl string, maxStale string, transport http.RoundTrippe
 	}
 
 	jwks := &JWKS{}
-	jwks.syncedJSON, err = jsn.NewSyncedJSON(file, "jwks_url", uri, transport, "jwks", timetolive, maxStaleTime, jwks)
+	jwks.syncedJSON, err = jsn.NewSyncedJSON(ctx, file, "jwks_url", uri, transport, "jwks", timetolive, maxStaleTime, jwks)
 	return jwks, err
 }
 
diff --git a/command/verify.go b/command/verify.go
index bf8783fc..a784df4b 100644
--- a/command/verify.go
+++ b/command/verify.go
@@ -1,6 +1,8 @@
 package command
 
 import (
+	"context"
+
 	"github.com/avenga/couper/cache"
 	"github.com/avenga/couper/config"
 	"github.com/avenga/couper/config/configload"
@@ -36,6 +38,10 @@ func (v Verify) Execute(args Args, conf *config.Couper, logger *logrus.Entry) er
 
 	tmpMemStore := cache.New(logger, tmpStoreCh)
 
+	ctx, cancel := context.WithCancel(cf.Context)
+	cf.Context = ctx
+	defer cancel()
+
 	_, err = runtime.NewServerConfiguration(cf, logger, tmpMemStore)
 	if err != nil {
 		logger.WithError(err).Error()
diff --git a/config/runtime/server.go b/config/runtime/server.go
index e96052ed..9eb96dbb 100644
--- a/config/runtime/server.go
+++ b/config/runtime/server.go
@@ -447,7 +447,7 @@ func configureOidcConfigs(conf *config.Couper, confCtx *hcl.EvalContext, log *lo
 				}
 			}
 
-			oidcConfig, err := oidc.NewConfig(oidcConf, backends)
+			oidcConfig, err := oidc.NewConfig(conf.Context, oidcConf, backends)
 			if err != nil {
 				return nil, confErr.With(err)
 			}
@@ -583,7 +583,7 @@ func configureJWKS(jwtConf *config.JWT, confContext *hcl.EvalContext, log *logru
 		return nil, err
 	}
 
-	return jwk.NewJWKS(jwtConf.JWKsURL, jwtConf.JWKsTTL, jwtConf.JWKsMaxStale, backend)
+	return jwk.NewJWKS(conf.Context, jwtConf.JWKsURL, jwtConf.JWKsTTL, jwtConf.JWKsMaxStale, backend)
 }
 
 type protectedOptions struct {
diff --git a/eval/context.go b/eval/context.go
index 90f73db5..c4f19491 100644
--- a/eval/context.go
+++ b/eval/context.go
@@ -52,6 +52,8 @@ type Context struct {
 	jwtSigningConfigs map[string]*lib.JWTSigningConfig
 	saml              []*config.SAML
 	syncedVariables   *SyncedVariables
+
+	cloneMu sync.RWMutex
 }
 
 func NewContext(srcBytes [][]byte, defaults *config.Defaults, environment string) *Context {
@@ -112,6 +114,9 @@ func (c *Context) Value(key interface{}) interface{} {
 
 func (c *Context) WithClientRequest(req *http.Request) *Context {
 	c.backendsFn.Do(func() {
+		c.cloneMu.Lock()
+		defer c.cloneMu.Unlock()
+
 		if c.memStore == nil {
 			return
 		}
@@ -124,17 +129,7 @@ func (c *Context) WithClientRequest(req *http.Request) *Context {
 		}
 	})
 
-	ctx := &Context{
-		backends:          c.backends,
-		eval:              c.cloneEvalContext(),
-		inner:             c.inner,
-		memStore:          c.memStore,
-		memorize:          make(map[string]interface{}),
-		oauth2:            c.oauth2,
-		jwtSigningConfigs: c.jwtSigningConfigs,
-		saml:              c.saml[:],
-		syncedVariables:   NewSyncedVariables(),
-	}
+	ctx := c.clone()
 
 	if rc := req.Context(); rc != nil {
 		rc = context.WithValue(rc, request.ContextVariablesSynced, ctx.syncedVariables)
@@ -195,17 +190,7 @@ func (c *Context) WithClientRequest(req *http.Request) *Context {
 }
 
 func (c *Context) WithBeresp(beresp *http.Response, readBody bool) *Context {
-	ctx := &Context{
-		backends:          c.backends,
-		eval:              c.cloneEvalContext(),
-		inner:             c.inner,
-		memStore:          c.memStore,
-		memorize:          c.memorize,
-		oauth2:            c.oauth2,
-		jwtSigningConfigs: c.jwtSigningConfigs,
-		saml:              c.saml[:],
-		syncedVariables:   c.syncedVariables,
-	}
+	ctx := c.clone()
 	ctx.inner = context.WithValue(c.inner, request.ContextType, ctx)
 
 	resps := make(ContextMap)
@@ -234,6 +219,25 @@ func (c *Context) WithBeresp(beresp *http.Response, readBody bool) *Context {
 	return ctx
 }
 
+// clone returns a new copy of Context with possible field updates in mind.
+// Especially during startup some requests may be fired which use this cloned base Context.
+func (c *Context) clone() *Context {
+	c.cloneMu.RLock()
+	defer c.cloneMu.RUnlock()
+
+	return &Context{
+		backends:          c.backends,
+		eval:              c.cloneEvalContext(),
+		inner:             c.inner,
+		memStore:          c.memStore,
+		memorize:          make(map[string]interface{}),
+		oauth2:            c.oauth2,
+		jwtSigningConfigs: c.jwtSigningConfigs,
+		saml:              c.saml[:],
+		syncedVariables:   NewSyncedVariables(),
+	}
+}
+
 func newBerespValues(ctx context.Context, readBody bool, beresp *http.Response) (name string, bereqVal cty.Value, berespVal cty.Value) {
 	bereq := beresp.Request
 	name = "default"
@@ -296,7 +300,7 @@ func newBerespValues(ctx context.Context, readBody bool, beresp *http.Response)
 
 func (c *Context) syncBackendVariables() map[string]cty.Value {
 	backendsVariable := make(map[string]cty.Value)
-	for _, backend := range c.backends {
+	for _, backend := range c.backends[:] {
 		b, ok := backend.(seetie.Object)
 		if !ok {
 			continue
@@ -310,6 +314,9 @@ func (c *Context) syncBackendVariables() map[string]cty.Value {
 
 // WithJWTSigningConfigs initially sets up the lib.FnJWTSign function.
 func (c *Context) WithJWTSigningConfigs(configs map[string]*lib.JWTSigningConfig) *Context {
+	c.cloneMu.Lock()
+	defer c.cloneMu.Unlock()
+
 	c.jwtSigningConfigs = configs
 	if c.jwtSigningConfigs == nil {
 		c.jwtSigningConfigs = make(map[string]*lib.JWTSigningConfig)
@@ -320,6 +327,9 @@ func (c *Context) WithJWTSigningConfigs(configs map[string]*lib.JWTSigningConfig
 
 // WithOAuth2AC adds the OAuth2AC config structs.
 func (c *Context) WithOAuth2AC(os []*config.OAuth2AC) *Context {
+	c.cloneMu.Lock()
+	defer c.cloneMu.Unlock()
+
 	if c.oauth2 == nil {
 		c.oauth2 = make(map[string]config.OAuth2Authorization)
 	}
@@ -331,6 +341,9 @@ func (c *Context) WithOAuth2AC(os []*config.OAuth2AC) *Context {
 
 // WithOidcConfig adds the OidcConfig config structs.
 func (c *Context) WithOidcConfig(confs oidc.Configs) *Context {
+	c.cloneMu.Lock()
+	defer c.cloneMu.Unlock()
+
 	if c.oauth2 == nil {
 		c.oauth2 = make(map[string]config.OAuth2Authorization)
 	}
@@ -341,12 +354,18 @@ func (c *Context) WithOidcConfig(confs oidc.Configs) *Context {
 }
 
 func (c *Context) WithMemStore(store *cache.MemoryStore) *Context {
+	c.cloneMu.Lock()
+	defer c.cloneMu.Unlock()
+
 	c.memStore = store
 	return c
 }
 
 // WithSAML initially set up the saml configuration.
 func (c *Context) WithSAML(s []*config.SAML) *Context {
+	c.cloneMu.Lock()
+	defer c.cloneMu.Unlock()
+
 	c.saml = s
 	if c.saml == nil {
 		c.saml = make([]*config.SAML, 0)
diff --git a/json/synced_json.go b/json/synced_json.go
index a517e176..4c21cce7 100644
--- a/json/synced_json.go
+++ b/json/synced_json.go
@@ -33,7 +33,9 @@ type SyncedJSON struct {
 	fileMode    bool
 }
 
-func NewSyncedJSON(file, fileContext, uri string, transport http.RoundTripper, roundTripName string, ttl time.Duration, maxStale time.Duration, unmarshaller SyncedJSONUnmarshaller) (*SyncedJSON, error) {
+func NewSyncedJSON(
+	ctx context.Context, file, fileContext, uri string, transport http.RoundTripper, roundTripName string,
+	ttl time.Duration, maxStale time.Duration, unmarshaller SyncedJSONUnmarshaller) (*SyncedJSON, error) {
 	sj := &SyncedJSON{
 		dataRequest:   make(chan chan *dataRequest, 10),
 		maxStale:      maxStale,
@@ -50,7 +52,7 @@ func NewSyncedJSON(file, fileContext, uri string, transport http.RoundTripper, r
 		}
 		sj.fileMode = true
 	} else if transport != nil {
-		go sj.sync(context.Background()) // TODO: at least cmd cancel ctx (reload)
+		go sj.sync(ctx)
 	} else {
 		return nil, fmt.Errorf("synced JSON: missing both file and request")
 	}
@@ -70,7 +72,7 @@ func (s *SyncedJSON) sync(ctx context.Context) {
 
 	init()
 
-	err := s.fetch() // initial fetch, provide any startup errors for first dataRequest's
+	err := s.fetch(ctx) // initial fetch, provide any startup errors for first dataRequests
 	if err != nil {
 		expired = time.After(0)
 	}
@@ -80,7 +82,7 @@ func (s *SyncedJSON) sync(ctx context.Context) {
 		case <-ctx.Done():
 			return
 		case <-expired:
-			err = s.fetch()
+			err = s.fetch(ctx)
 			if err != nil {
 				invalidated = time.After(s.maxStale)
 				expired = time.After(backoff)
@@ -113,11 +115,13 @@ func (s *SyncedJSON) Data() (interface{}, error) {
 }
 
 // fetch blocks all data reads until we will have an updated one.
-func (s *SyncedJSON) fetch() error {
+func (s *SyncedJSON) fetch(ctx context.Context) error {
 	req, _ := http.NewRequest("GET", s.uri, nil)
 
-	ctx := context.WithValue(context.Background(), request.RoundTripName, s.roundTripName)
-	req = req.WithContext(ctx)
+	outCtx, cancel := context.WithCancel(context.WithValue(ctx, request.RoundTripName, s.roundTripName))
+	defer cancel()
+
+	req = req.WithContext(outCtx)
 
 	response, err := s.transport.RoundTrip(req)
 	if err != nil {
diff --git a/main.go b/main.go
index 9269ca3d..35df08b5 100644
--- a/main.go
+++ b/main.go
@@ -231,12 +231,12 @@ func realmain(ctx context.Context, arguments []string) int {
 			tmpMemStore := cache.New(logger, tmpStoreCh)
 
 			dryCtx, cancelDry := context.
-				WithCancel(context.WithValue(context.Background(), request.ConfigDryRun, true))
+				WithCancel(context.WithValue(ctx, request.ConfigDryRun, true))
 			cf.Context = cf.Context.(*eval.Context).WithContext(dryCtx)
 
 			_, reloadErr = runtime.NewServerConfiguration(cf, logger.WithFields(fields), tmpMemStore)
 			close(tmpStoreCh)
-			cancelDry()
+			cancelDry() // Cancels the context of cf
 
 			if reloadErr != nil {
 				logger.WithError(reloadErr).Error("reload failed")
@@ -244,7 +244,9 @@ func realmain(ctx context.Context, arguments []string) int {
 				continue
 			}
 
-			confFile = cf
+			// Create new config with non-canceled context.
+			confFile, _ = configload.LoadFiles(filesList.paths, flags.Environment)
+
 			restartSignal <- struct{}{}                              // shutdown running couper
 			<-errCh                                                  // drain current error due to cancel and ensure closed ports
 			execCmd, restartSignal = newRestartableCommand(ctx, cmd) // replace previous pair
diff --git a/oauth2/client.go b/oauth2/client.go
index c42db3ca..b1bc8746 100644
--- a/oauth2/client.go
+++ b/oauth2/client.go
@@ -37,7 +37,10 @@ func NewClient(grantType string, asConfig config.OAuth2AS, clientConfig config.O
 }
 
 func (c *Client) requestToken(tokenReq *http.Request) ([]byte, int, error) {
-	tokenRes, err := c.backend.RoundTrip(tokenReq)
+	ctx, cancel := context.WithCancel(tokenReq.Context())
+	defer cancel()
+
+	tokenRes, err := c.backend.RoundTrip(tokenReq.WithContext(ctx))
 	if err != nil {
 		return nil, 0, err
 	}
diff --git a/oauth2/oidc/config.go b/oauth2/oidc/config.go
index 7ec0cae9..1ae3e4cc 100644
--- a/oauth2/oidc/config.go
+++ b/oauth2/oidc/config.go
@@ -1,7 +1,9 @@
 package oidc
 
 import (
+	"bytes"
 	"context"
+	"crypto/sha256"
 	"encoding/json"
 	"fmt"
 	"net/http"
@@ -38,16 +40,18 @@ var (
 // Config represents the configuration for an OIDC client
 type Config struct {
 	*config.OIDC
-	backends   map[string]http.RoundTripper
-	context    context.Context
-	syncedJSON *jsn.SyncedJSON
-	jwks       *jwk.JWKS
-	cmu        sync.RWMutex // conf
-	jmu        sync.RWMutex // jkws
+	backends     map[string]http.RoundTripper
+	context      context.Context
+	syncedJSON   *jsn.SyncedJSON
+	jwks         *jwk.JWKS
+	jwksCheckSum [32]byte
+	jwksCancel   func()
+	cmu          sync.RWMutex // conf
+	jmu          sync.RWMutex // jkws
 }
 
 // NewConfig creates a new configuration for an OIDC client
-func NewConfig(oidc *config.OIDC, backends map[string]http.RoundTripper) (*Config, error) {
+func NewConfig(ctx context.Context, oidc *config.OIDC, backends map[string]http.RoundTripper) (*Config, error) {
 	ttl, err := config.ParseDuration("configuration_ttl", oidc.ConfigurationTTL, defaultTTL)
 	if err != nil {
 		return nil, err
@@ -57,14 +61,13 @@ func NewConfig(oidc *config.OIDC, backends map[string]http.RoundTripper) (*Confi
 		return nil, err
 	}
 
-	ctx := context.Background()
 	conf := &Config{
 		OIDC:     oidc,
 		backends: backends,
 		context:  ctx,
 	}
 
-	conf.syncedJSON, err = jsn.NewSyncedJSON("", "",
+	conf.syncedJSON, err = jsn.NewSyncedJSON(ctx, "", "",
 		oidc.ConfigurationURL, backends["configuration_backend"], oidc.Name, ttl, maxStale, conf)
 	if err != nil {
 		return nil, err
@@ -168,6 +171,13 @@ func (c *Config) Unmarshal(rawJSON []byte) (interface{}, error) {
 		return nil, err
 	}
 
+	checkSum := sha256.Sum256(rawJSON)
+	if bytes.Equal(checkSum[:], c.jwksCheckSum[:]) {
+		// return obtained (same) data here since Data() call will block
+		return jsonData, nil
+	}
+	c.jwksCheckSum = checkSum
+
 	if c.OIDC.VerifierMethod == "" {
 		if supportsS256(jsonData.CodeChallengeMethodsSupported) {
 			c.OIDC.VerifierMethod = config.CcmS256
@@ -181,11 +191,20 @@ func (c *Config) Unmarshal(rawJSON []byte) (interface{}, error) {
 		c.backends["jwks_uri_backend"],
 	)
 
-	newJWKS, err := jwk.NewJWKS(jsonData.JwksUri, c.OIDC.JWKsTTL, c.OIDC.JWKsMaxStale, jwksBackend)
+	ctx, cancel := context.WithCancel(c.context)
+
+	newJWKS, err := jwk.NewJWKS(ctx, jsonData.JwksUri, c.OIDC.JWKsTTL, c.OIDC.JWKsMaxStale, jwksBackend)
 	if err != nil { // do not replace possible working jwks on err
+		cancel()
 		return jsonData, err
 	}
 
+	// free up inner syncedJSON goroutine
+	if c.jwksCancel != nil {
+		c.jwksCancel()
+	}
+	// update with newly created one
+	c.jwksCancel = cancel
 	c.jwks = newJWKS
 
 	return jsonData, nil
diff --git a/oauth2/oidc_client.go b/oauth2/oidc_client.go
index accebd46..7d6504a3 100644
--- a/oauth2/oidc_client.go
+++ b/oauth2/oidc_client.go
@@ -236,7 +236,10 @@ func (o *OidcClient) getUserinfo(ctx context.Context, accessToken string) (map[s
 }
 
 func (o *OidcClient) requestUserinfo(userinfoReq *http.Request) ([]byte, error) {
-	userinfoRes, err := o.backends["userinfo_backend"].RoundTrip(userinfoReq)
+	ctx, cancel := context.WithCancel(userinfoReq.Context())
+	defer cancel()
+
+	userinfoRes, err := o.backends["userinfo_backend"].RoundTrip(userinfoReq.WithContext(ctx))
 	if err != nil {
 		return nil, err
 	}


Test patch:
diff --git a/accesscontrol/jwk/jwks_test.go b/accesscontrol/jwk/jwks_test.go
index ede40257..3c64c16d 100644
--- a/accesscontrol/jwk/jwks_test.go
+++ b/accesscontrol/jwk/jwks_test.go
@@ -41,7 +41,7 @@ func Test_JWKS(t *testing.T) {
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(subT *testing.T) {
-			_, err := jwk.NewJWKS(tt.url, "", "", backend)
+			_, err := jwk.NewJWKS(context.TODO(), tt.url, "", "", backend)
 			if err == nil && tt.error != "" {
 				subT.Errorf("Missing error:\n\tWant: %v\n\tGot:  %v", tt.error, nil)
 			}
@@ -64,7 +64,7 @@ func Test_JWKS_Load(t *testing.T) {
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(subT *testing.T) {
-			jwks, err := jwk.NewJWKS("file:"+tt.file, "", "", nil)
+			jwks, err := jwk.NewJWKS(context.TODO(), "file:"+tt.file, "", "", nil)
 			helper.Must(err)
 			_, err = jwks.Data()
 			if err != nil && tt.expParsed {
@@ -93,7 +93,7 @@ func Test_JWKS_GetSigKeyForToken(t *testing.T) {
 	for _, tt := range tests {
 		t.Run(tt.name, func(subT *testing.T) {
 			helper := test.New(subT)
-			jwks, err := jwk.NewJWKS("file:"+tt.file, "", "", nil)
+			jwks, err := jwk.NewJWKS(context.TODO(), "file:"+tt.file, "", "", nil)
 			helper.Must(err)
 			_, err = jwks.Data()
 			helper.Must(err)
@@ -145,7 +145,7 @@ func Test_JWKS_GetKey(t *testing.T) {
 	for _, tt := range tests {
 		t.Run(tt.name, func(subT *testing.T) {
 			helper := test.New(subT)
-			jwks, err := jwk.NewJWKS("file:"+tt.file, "", "", nil)
+			jwks, err := jwk.NewJWKS(context.TODO(), "file:"+tt.file, "", "", nil)
 			helper.Must(err)
 			_, err = jwks.Data()
 			helper.Must(err)
@@ -175,7 +175,7 @@ func Test_JWKS_LoadSynced(t *testing.T) {
 		io.Copy(writer, bytes.NewReader(f))
 	}))
 
-	jwks, err := jwk.NewJWKS(jwksOrigin.URL, "10s", "", http.DefaultTransport)
+	jwks, err := jwk.NewJWKS(context.TODO(), jwksOrigin.URL, "10s", "", http.DefaultTransport)
 	helper.Must(err)
 
 	wg := sync.WaitGroup{}
diff --git a/accesscontrol/jwt_test.go b/accesscontrol/jwt_test.go
index cf853108..e07d2d43 100644
--- a/accesscontrol/jwt_test.go
+++ b/accesscontrol/jwt_test.go
@@ -905,6 +905,10 @@ func TestJwtConfig(t *testing.T) {
 				tmpStoreCh := make(chan struct{})
 				defer close(tmpStoreCh)
 
+				ctx, cancel := context.WithCancel(conf.Context)
+				conf.Context = ctx
+				defer cancel()
+
 				_, err = runtime.NewServerConfiguration(conf, logger, cache.New(logger, tmpStoreCh))
 			}
 
diff --git a/config/configload/load_test.go b/config/configload/load_test.go
index 12d5da0e..ab486e96 100644
--- a/config/configload/load_test.go
+++ b/config/configload/load_test.go
@@ -192,6 +192,10 @@ func TestHealthCheck(t *testing.T) {
 			memStore := cache.New(log, closeCh)
 
 			if conf != nil {
+				ctx, cancel := context.WithCancel(conf.Context)
+				conf.Context = ctx
+				defer cancel()
+
 				_, err = runtime.NewServerConfiguration(conf, log, memStore)
 			}
 
@@ -257,6 +261,10 @@ func TestRateLimit(t *testing.T) {
 			memStore := cache.New(log, closeCh)
 
 			if conf != nil {
+				ctx, cancel := context.WithCancel(conf.Context)
+				conf.Context = ctx
+				defer cancel()
+
 				_, err = runtime.NewServerConfiguration(conf, log, memStore)
 			}
 
diff --git a/config/configload/validate_test.go b/config/configload/validate_test.go
index 7f1980a1..9023c73d 100644
--- a/config/configload/validate_test.go
+++ b/config/configload/validate_test.go
@@ -207,6 +207,10 @@ func TestLabels(t *testing.T) {
 				tmpStoreCh := make(chan struct{})
 				defer close(tmpStoreCh)
 
+				ctx, cancel := context.WithCancel(conf.Context)
+				conf.Context = ctx
+				defer cancel()
+
 				_, err = runtime.NewServerConfiguration(conf, log, cache.New(log, tmpStoreCh))
 			}
 
diff --git a/config/runtime/access_control_test.go b/config/runtime/access_control_test.go
index 927d11c1..6d8715f4 100644
--- a/config/runtime/access_control_test.go
+++ b/config/runtime/access_control_test.go
@@ -224,6 +224,11 @@ func TestDuplicateEndpoint(t *testing.T) {
 			logger := log.WithContext(context.TODO())
 			tmpStoreCh := make(chan struct{})
 			defer close(tmpStoreCh)
+
+			ctx, cancel := context.WithCancel(conf.Context)
+			conf.Context = ctx
+			defer cancel()
+
 			server, err := runtime.NewServerConfiguration(conf, logger, cache.New(logger, tmpStoreCh))
 
 			if err != nil {
diff --git a/eval/lib/oauth2_test.go b/eval/lib/oauth2_test.go
index afc898b4..82b9cac0 100644
--- a/eval/lib/oauth2_test.go
+++ b/eval/lib/oauth2_test.go
@@ -69,6 +69,10 @@ definitions {
 	defer close(quitCh)
 	memStore := cache.New(logger, quitCh)
 
+	ctx, cancel := context.WithCancel(couperConf.Context)
+	couperConf.Context = ctx
+	defer cancel()
+
 	_, err = runtime.NewServerConfiguration(couperConf, logger, memStore)
 	helper.Must(err)
 
diff --git a/internal/test/goroutine.go b/internal/test/goroutine.go
new file mode 100644
index 00000000..8e7d9fda
--- /dev/null
+++ b/internal/test/goroutine.go
@@ -0,0 +1,37 @@
+package test
+
+import (
+	"bufio"
+	"bytes"
+	"io"
+	"regexp"
+	"runtime/pprof"
+	"strconv"
+)
+
+func NumGoroutines(filter string) (numRoutine int) {
+	profile := pprof.Lookup("goroutine")
+	profileBuf := &bytes.Buffer{}
+	_ = profile.WriteTo(profileBuf, 1)
+	pr := bufio.NewReader(profileBuf)
+
+	stackRegex := regexp.MustCompile(`(\d+)\s@\s0x`)
+	for {
+		line, _, readErr := pr.ReadLine()
+		if readErr != nil {
+			if readErr == io.EOF {
+				break
+			}
+			panic(readErr)
+		}
+		match := stackRegex.FindSubmatch(line)
+		if len(match) > 1 {
+			numRoutine, _ = strconv.Atoi(string(match[1]))
+			continue
+		}
+		if bytes.Contains(line, []byte(filter)) {
+			return numRoutine
+		}
+	}
+	return -1
+}
diff --git a/json/synced_json_test.go b/json/synced_json_test.go
index 3ed046d9..cb055e49 100644
--- a/json/synced_json_test.go
+++ b/json/synced_json_test.go
@@ -1,6 +1,7 @@
 package json_test
 
 import (
+	"context"
 	"encoding/json"
 	"fmt"
 	"net/http"
@@ -43,7 +44,7 @@ func Test_LoadSynced(t *testing.T) {
 	}))
 	defer origin.Close()
 
-	syncedJSON, err := jsn.NewSyncedJSON("", "", origin.URL, http.DefaultTransport, "test", time.Second*2, time.Hour, &unmarshaller{})
+	syncedJSON, err := jsn.NewSyncedJSON(context.TODO(), "", "", origin.URL, http.DefaultTransport, "test", time.Second*2, time.Hour, &unmarshaller{})
 	helper.Must(err)
 
 	expectJSONValue := func(expectedValue int, shouldFail bool) {
diff --git a/oauth2/oidc/config_test.go b/oauth2/oidc/config_test.go
index 996380a9..768b9107 100644
--- a/oauth2/oidc/config_test.go
+++ b/oauth2/oidc/config_test.go
@@ -7,6 +7,7 @@ import (
 	"net/http/httptest"
 	"sync"
 	"testing"
+	"time"
 
 	"github.com/hashicorp/hcl/v2"
 	"github.com/hashicorp/hcl/v2/hclsyntax"
@@ -24,8 +25,11 @@ import (
 func TestConfig_Synced(t *testing.T) {
 	helper := test.New(t)
 
+	ctx, cancel := context.WithCancel(context.Background())
+	defer cancel()
+
 	log, _ := test.NewLogger()
-	logger := log.WithContext(context.Background())
+	logger := log.WithContext(ctx)
 
 	var origin *httptest.Server
 	origin = httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
@@ -52,6 +56,7 @@ func TestConfig_Synced(t *testing.T) {
 
 	oconf := &config.OIDC{
 		ConfigurationURL: origin.URL + "/.well-known/openid-configuration",
+		ConfigurationTTL: "100ms",
 		Remain: hclbody.New(&hcl.BodyContent{Attributes: map[string]*hcl.Attribute{
 			"redirect_uri":   {Name: "redirect_uri", Expr: &hclsyntax.LiteralValueExpr{Val: cty.StringVal("")}},
 			"verifier_value": {Name: "verifier_value", Expr: &hclsyntax.LiteralValueExpr{Val: cty.StringVal("")}},
@@ -68,7 +73,7 @@ func TestConfig_Synced(t *testing.T) {
 		backends[k] = transport.NewBackend(b, &transport.Config{}, nil, logger)
 	}
 
-	o, err := oidc.NewConfig(oconf, backends)
+	o, err := oidc.NewConfig(ctx, oconf, backends)
 	helper.Must(err)
 
 	wg := sync.WaitGroup{}
@@ -82,4 +87,11 @@ func TestConfig_Synced(t *testing.T) {
 		}(i)
 	}
 	wg.Wait()
+
+	// wait for possible goroutine leaks from syncedJSON due to low ttl
+	time.Sleep(time.Second / 2)
+
+	if n := test.NumGoroutines("json.(*SyncedJSON).sync"); n != 2 {
+		t.Errorf("Expected two running routines, got: %d", n)
+	}
 }
diff --git a/server/http_oauth2_test.go b/server/http_oauth2_test.go
index 0a2f70c1..2eb721d9 100644
--- a/server/http_oauth2_test.go
+++ b/server/http_oauth2_test.go
@@ -511,6 +511,10 @@ definitions {
 			tmpStoreCh := make(chan struct{})
 			defer close(tmpStoreCh)
 
+			ctx, cancel := context.WithCancel(conf.Context)
+			conf.Context = ctx
+			defer cancel()
+
 			_, err = runtime.NewServerConfiguration(conf, logger, cache.New(logger, tmpStoreCh))
 		}
 
diff --git a/server/http_test.go b/server/http_test.go
index 61663fbc..04a20601 100644
--- a/server/http_test.go
+++ b/server/http_test.go
@@ -76,6 +76,10 @@ func TestHTTPServer_ServeHTTP_Files(t *testing.T) {
 	logger := log.WithContext(context.TODO())
 	tmpMemStore := cache.New(logger, tmpStoreCh)
 
+	confCTX, confCancel := context.WithCancel(conf.Context)
+	conf.Context = confCTX
+	defer confCancel()
+
 	srvConf, err := runtime.NewServerConfiguration(conf, logger, tmpMemStore)
 	helper.Must(err)
 
@@ -178,6 +182,10 @@ func TestHTTPServer_ServeHTTP_Files2(t *testing.T) {
 	logger := log.WithContext(context.TODO())
 	tmpMemStore := cache.New(logger, tmpStoreCh)
 
+	confCTX, confCancel := context.WithCancel(conf.Context)
+	conf.Context = confCTX
+	defer confCancel()
+
 	srvConf, err := runtime.NewServerConfiguration(conf, logger, tmpMemStore)
 	helper.Must(err)