diff --git a/js/bundle.go b/js/bundle.go index c22b6346592..c5a56fdad31 100644 --- a/js/bundle.go +++ b/js/bundle.go @@ -309,10 +309,9 @@ func (b *Bundle) instantiate(logger logrus.FieldLogger, rt *goja.Runtime, init * // TODO: get rid of the unused ctxPtr, use a real external context (so we // can interrupt), build the common.InitEnvironment earlier and reuse it initenv := &common.InitEnvironment{ - SharedObjects: init.sharedObjects, - Logger: logger, - FileSystems: init.filesystems, - CWD: init.pwd, + Logger: logger, + FileSystems: init.filesystems, + CWD: init.pwd, } ctx := common.WithInitEnv(context.Background(), initenv) *init.ctxPtr = common.WithRuntime(ctx, rt) diff --git a/js/common/initenv.go b/js/common/initenv.go index b5ba4037bad..be0a1b116ef 100644 --- a/js/common/initenv.go +++ b/js/common/initenv.go @@ -23,7 +23,6 @@ package common import ( "net/url" "path/filepath" - "sync" "github.com/sirupsen/logrus" "github.com/spf13/afero" @@ -38,7 +37,6 @@ type InitEnvironment struct { // TODO: add RuntimeOptions and other properties, goja sources, etc. // ideally, we should leave this as the only data structure necessary for // executing the init context for all JS modules - SharedObjects *SharedObjects } // GetAbsFilePath should be used to access the FileSystems, since afero has a @@ -62,34 +60,3 @@ func (ie *InitEnvironment) GetAbsFilePath(filename string) string { } return filename } - -// SharedObjects is a collection of general store for objects to be shared. It is mostly a wrapper -// around map[string]interface with a lock and stuff. -// The reason behind not just using sync.Map is that it still needs a lock when we want to only call -// the function constructor if there is no such key at which point you already need a lock so ... -type SharedObjects struct { - data map[string]interface{} - l sync.Mutex -} - -// NewSharedObjects returns a new SharedObjects ready to use -func NewSharedObjects() *SharedObjects { - return &SharedObjects{ - data: make(map[string]interface{}), - } -} - -// GetOrCreateShare returns a shared value with the given name or sets it's value whatever -// createCallback returns and returns it. -func (so *SharedObjects) GetOrCreateShare(name string, createCallback func() interface{}) interface{} { - so.l.Lock() - defer so.l.Unlock() - - value, ok := so.data[name] - if !ok { - value = createCallback() - so.data[name] = value - } - - return value -} diff --git a/js/init_and_modules_test.go b/js/init_and_modules_test.go index 40b91530126..29e8162e068 100644 --- a/js/init_and_modules_test.go +++ b/js/init_and_modules_test.go @@ -22,13 +22,15 @@ package js_test import ( "context" + "fmt" "net/url" + "sync/atomic" "testing" "time" "github.com/loadimpact/k6/js" "github.com/loadimpact/k6/js/common" - "github.com/loadimpact/k6/js/internal/modules" + "github.com/loadimpact/k6/js/modules" "github.com/loadimpact/k6/lib" "github.com/loadimpact/k6/lib/testutils" "github.com/loadimpact/k6/loader" @@ -59,19 +61,23 @@ func (cm *CheckModule) VuCtx(ctx context.Context) { assert.NotNil(cm.t, lib.GetState(ctx)) } +var uniqueModuleNumber int64 //nolint:gochecknoglobals + func TestNewJSRunnerWithCustomModule(t *testing.T) { + t.Parallel() checkModule := &CheckModule{t: t} - modules.Register("k6/check", checkModule) + moduleName := fmt.Sprintf("k6/x/check-%d", atomic.AddInt64(&uniqueModuleNumber, 1)) + modules.Register(moduleName, checkModule) - script := ` - var check = require("k6/check"); + script := fmt.Sprintf(` + var check = require("%s"); check.initCtx(); module.exports.options = { vus: 1, iterations: 1 }; module.exports.default = function() { check.vuCtx(); }; - ` + `, moduleName) logger := testutils.NewLogger(t) rtOptions := lib.RuntimeOptions{CompatibilityMode: null.StringFrom("base")} diff --git a/js/initcontext.go b/js/initcontext.go index cd25fefb368..eeb286694d0 100644 --- a/js/initcontext.go +++ b/js/initcontext.go @@ -34,7 +34,7 @@ import ( "github.com/loadimpact/k6/js/common" "github.com/loadimpact/k6/js/compiler" - "github.com/loadimpact/k6/js/internal/modules" + "github.com/loadimpact/k6/js/modules" "github.com/loadimpact/k6/lib" "github.com/loadimpact/k6/loader" ) @@ -70,7 +70,7 @@ type InitContext struct { logger logrus.FieldLogger - sharedObjects *common.SharedObjects + modules map[string]interface{} } // NewInitContext creates a new initcontext with the provided arguments @@ -87,7 +87,7 @@ func NewInitContext( programs: make(map[string]programWithSource), compatibilityMode: compatMode, logger: logger, - sharedObjects: common.NewSharedObjects(), + modules: modules.GetJSModules(), } } @@ -113,7 +113,7 @@ func newBoundInitContext(base *InitContext, ctxPtr *context.Context, rt *goja.Ru programs: programs, compatibilityMode: base.compatibilityMode, logger: base.logger, - sharedObjects: base.sharedObjects, + modules: base.modules, } } @@ -140,10 +140,13 @@ func (i *InitContext) Require(arg string) goja.Value { } func (i *InitContext) requireModule(name string) (goja.Value, error) { - mod := modules.Get(name) - if mod == nil { + mod, ok := i.modules[name] + if !ok { return nil, fmt.Errorf("unknown module: %s", name) } + if perInstance, ok := mod.(modules.HasModuleInstancePerVU); ok { + mod = perInstance.NewModuleInstancePerVU() + } return i.runtime.ToValue(common.Bind(i.runtime, mod, i.ctxPtr)), nil } diff --git a/js/internal/modules/modules.go b/js/internal/modules/modules.go deleted file mode 100644 index 01f8d69baed..00000000000 --- a/js/internal/modules/modules.go +++ /dev/null @@ -1,63 +0,0 @@ -/* - * - * k6 - a next-generation load testing tool - * Copyright (C) 2020 Load Impact - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - */ - -package modules - -import ( - "fmt" - "sync" -) - -//nolint:gochecknoglobals -var ( - modules = make(map[string]interface{}) - mx sync.RWMutex -) - -// Get returns the module registered with name. -func Get(name string) interface{} { - mx.RLock() - defer mx.RUnlock() - mod := modules[name] - if i, ok := mod.(HasModuleInstancePerVU); ok { - return i.NewModuleInstancePerVU() - } - return mod -} - -// HasModuleInstancePerVU should be implemented by all native Golang modules that -// would require per-VU state. k6 will call their NewModuleInstancePerVU() methods -// every time a VU imports the module and use its result as the returned object. -type HasModuleInstancePerVU interface { - NewModuleInstancePerVU() interface{} -} - -// Register the given mod as a JavaScript module, available -// for import from JS scripts by name. -// This function panics if a module with the same name is already registered. -func Register(name string, mod interface{}) { - mx.Lock() - defer mx.Unlock() - - if _, ok := modules[name]; ok { - panic(fmt.Sprintf("module already registered: %s", name)) - } - modules[name] = mod -} diff --git a/js/modules/k6/crypto/crypto.go b/js/modules/k6/crypto/crypto.go index 226e1412100..94910f2cf9e 100644 --- a/js/modules/k6/crypto/crypto.go +++ b/js/modules/k6/crypto/crypto.go @@ -37,13 +37,8 @@ import ( "golang.org/x/crypto/ripemd160" "github.com/loadimpact/k6/js/common" - "github.com/loadimpact/k6/js/internal/modules" ) -func init() { - modules.Register("k6/crypto", New()) -} - type Crypto struct{} type Hasher struct { diff --git a/js/modules/k6/crypto/x509/x509.go b/js/modules/k6/crypto/x509/x509.go index c059a3b5cb8..deb1aa0f0cd 100644 --- a/js/modules/k6/crypto/x509/x509.go +++ b/js/modules/k6/crypto/x509/x509.go @@ -34,13 +34,8 @@ import ( "time" "github.com/loadimpact/k6/js/common" - "github.com/loadimpact/k6/js/internal/modules" ) -func init() { - modules.Register("k6/crypto/x509", New()) -} - // X509 certificate functionality type X509 struct{} @@ -223,7 +218,7 @@ func iso8601(value time.Time) string { } func makeRdns(names []pkix.AttributeTypeAndValue) []RDN { - var result = make([]RDN, len(names)) + result := make([]RDN, len(names)) for i, name := range names { result[i] = makeRdn(name) } diff --git a/js/modules/k6/data/data.go b/js/modules/k6/data/data.go index e5b6e36fcff..dc365b212f6 100644 --- a/js/modules/k6/data/data.go +++ b/js/modules/k6/data/data.go @@ -24,20 +24,49 @@ import ( "context" "errors" "strconv" + "sync" "github.com/dop251/goja" "github.com/loadimpact/k6/js/common" - "github.com/loadimpact/k6/js/internal/modules" "github.com/loadimpact/k6/lib" ) -type data struct{} +type data struct { + shared sharedArrays +} + +type sharedArrays struct { + data map[string]sharedArray + mu sync.RWMutex +} + +func (s *sharedArrays) get(rt *goja.Runtime, name string, call goja.Callable) sharedArray { + s.mu.RLock() + array, ok := s.data[name] + s.mu.RUnlock() + if !ok { + s.mu.Lock() + array, ok = s.data[name] + if !ok { + func() { // this is done for the defer below + defer s.mu.Unlock() + array = getShareArrayFromCall(rt, call) + s.data[name] = array + }() + } + } -func init() { - modules.Register("k6/data", new(data)) + return array } -const sharedArrayNamePrefix = "k6/data/SharedArray." +// New return a new Module instance +func New() interface{} { + return &data{ + shared: sharedArrays{ + data: make(map[string]sharedArray), + }, + } +} // XSharedArray is a constructor returning a shareable read-only array // indentified by the name and having their contents be whatever the call returns @@ -46,24 +75,14 @@ func (d *data) XSharedArray(ctx context.Context, name string, call goja.Callable return nil, errors.New("new SharedArray must be called in the init context") } - initEnv := common.GetInitEnv(ctx) - if initEnv == nil { - return nil, errors.New("missing init environment") - } if len(name) == 0 { return nil, errors.New("empty name provided to SharedArray's constructor") } - name = sharedArrayNamePrefix + name - value := initEnv.SharedObjects.GetOrCreateShare(name, func() interface{} { - return getShareArrayFromCall(common.GetRuntime(ctx), call) - }) - array, ok := value.(sharedArray) - if !ok { // TODO more info in the error? - return nil, errors.New("wrong type of shared object") - } + rt := common.GetRuntime(ctx) + array := d.shared.get(rt, name, call) - return array.wrap(common.GetRuntime(ctx)), nil + return array.wrap(rt), nil } func getShareArrayFromCall(rt *goja.Runtime, call goja.Callable) sharedArray { diff --git a/js/modules/k6/data/share_test.go b/js/modules/k6/data/share_test.go index 0265b531d50..294ed69ebc5 100644 --- a/js/modules/k6/data/share_test.go +++ b/js/modules/k6/data/share_test.go @@ -40,24 +40,22 @@ var array = new data.SharedArray("shared",function() { }); ` -func newConfiguredRuntime(initEnv *common.InitEnvironment) (*goja.Runtime, error) { +func newConfiguredRuntime(moduleInstance interface{}) (*goja.Runtime, error) { rt := goja.New() rt.SetFieldNameMapper(common.FieldNameMapper{}) + ctx := common.WithRuntime(context.Background(), rt) + err := rt.Set("data", common.Bind(rt, moduleInstance, &ctx)) + if err != nil { + return rt, err //nolint:wrapcheck + } + _, err = rt.RunString("var SharedArray = data.SharedArray;") - ctx := common.WithInitEnv(context.Background(), initEnv) - ctx = common.WithRuntime(ctx, rt) - rt.Set("data", common.Bind(rt, new(data), &ctx)) - _, err := rt.RunString("var SharedArray = data.SharedArray;") - - return rt, err + return rt, err //nolint:wrapcheck } func TestSharedArrayConstructorExceptions(t *testing.T) { t.Parallel() - initEnv := &common.InitEnvironment{ - SharedObjects: common.NewSharedObjects(), - } - rt, err := newConfiguredRuntime(initEnv) + rt, err := newConfiguredRuntime(New()) require.NoError(t, err) cases := map[string]struct { code, err string @@ -100,16 +98,13 @@ func TestSharedArrayConstructorExceptions(t *testing.T) { func TestSharedArrayAnotherRuntimeExceptions(t *testing.T) { t.Parallel() - initEnv := &common.InitEnvironment{ - SharedObjects: common.NewSharedObjects(), - } - rt, err := newConfiguredRuntime(initEnv) + moduleInstance := New() + rt, err := newConfiguredRuntime(moduleInstance) require.NoError(t, err) _, err = rt.RunString(makeArrayScript) require.NoError(t, err) - // create another Runtime with new ctx but keep the initEnv - rt, err = newConfiguredRuntime(initEnv) + rt, err = newConfiguredRuntime(moduleInstance) require.NoError(t, err) _, err = rt.RunString(makeArrayScript) require.NoError(t, err) @@ -155,18 +150,16 @@ func TestSharedArrayAnotherRuntimeExceptions(t *testing.T) { func TestSharedArrayAnotherRuntimeWorking(t *testing.T) { t.Parallel() - initEnv := &common.InitEnvironment{ - SharedObjects: common.NewSharedObjects(), - } - rt, err := newConfiguredRuntime(initEnv) + moduleInstance := New() + rt, err := newConfiguredRuntime(moduleInstance) require.NoError(t, err) _, err = rt.RunString(makeArrayScript) require.NoError(t, err) // create another Runtime with new ctx but keep the initEnv - rt, err = newConfiguredRuntime(initEnv) + rt, err = newConfiguredRuntime(moduleInstance) require.NoError(t, err) - _, err = rt.RunString(makeArrayScript) + _, err = rt.RunString(`var array = new data.SharedArray("shared", function() {throw "wat";});`) require.NoError(t, err) _, err = rt.RunString(` diff --git a/js/modules/k6/encoding/encoding.go b/js/modules/k6/encoding/encoding.go index 914d2981e7a..8c2893dc233 100644 --- a/js/modules/k6/encoding/encoding.go +++ b/js/modules/k6/encoding/encoding.go @@ -25,13 +25,8 @@ import ( "encoding/base64" "github.com/loadimpact/k6/js/common" - "github.com/loadimpact/k6/js/internal/modules" ) -func init() { - modules.Register("k6/encoding", New()) -} - type Encoding struct{} func New() *Encoding { diff --git a/js/modules/k6/grpc/grpc.go b/js/modules/k6/grpc/grpc.go index 5864e96ecea..83e41bfaff8 100644 --- a/js/modules/k6/grpc/grpc.go +++ b/js/modules/k6/grpc/grpc.go @@ -22,14 +22,8 @@ package grpc import ( "google.golang.org/grpc/codes" - - "github.com/loadimpact/k6/js/internal/modules" ) -func init() { - modules.Register("k6/net/grpc", New()) -} - // GRPC represents the gRPC protocol module for k6 type GRPC struct { StatusOK codes.Code `js:"StatusOK"` diff --git a/js/modules/k6/html/html.go b/js/modules/k6/html/html.go index e70b65f9c90..040517a1b60 100644 --- a/js/modules/k6/html/html.go +++ b/js/modules/k6/html/html.go @@ -31,13 +31,8 @@ import ( gohtml "golang.org/x/net/html" "github.com/loadimpact/k6/js/common" - "github.com/loadimpact/k6/js/internal/modules" ) -func init() { - modules.Register("k6/html", New()) -} - type HTML struct{} func New() *HTML { @@ -78,7 +73,6 @@ func (s Selection) varargFnCall(arg interface{}, strFilter func(string) *goquery.Selection, selFilter func(*goquery.Selection) *goquery.Selection, nodeFilter func(...*gohtml.Node) *goquery.Selection) Selection { - switch v := arg.(type) { case Selection: return Selection{s.rt, selFilter(v.sel), s.URL} @@ -113,7 +107,6 @@ func (s Selection) adjacentUntil(until func(string) *goquery.Selection, filteredUntil func(string, string) *goquery.Selection, filteredUntilSelection func(string, *goquery.Selection) *goquery.Selection, def ...goja.Value) Selection { - switch len(def) { case 0: return Selection{s.rt, until(""), s.URL} diff --git a/js/modules/k6/http/http.go b/js/modules/k6/http/http.go index 412cbd72f92..de4f0ff9bdf 100644 --- a/js/modules/k6/http/http.go +++ b/js/modules/k6/http/http.go @@ -24,15 +24,10 @@ import ( "context" "github.com/loadimpact/k6/js/common" - "github.com/loadimpact/k6/js/internal/modules" "github.com/loadimpact/k6/lib" "github.com/loadimpact/k6/lib/netext" ) -func init() { - modules.Register("k6/http", new(GlobalHTTP)) -} - const ( HTTP_METHOD_GET = "GET" HTTP_METHOD_POST = "POST" @@ -46,11 +41,14 @@ const ( // ErrJarForbiddenInInitContext is used when a cookie jar was made in the init context var ErrJarForbiddenInInitContext = common.NewInitContextError("Making cookie jars in the init context is not supported") +// New returns a new global module instance +func New() *GlobalHTTP { + return &GlobalHTTP{} +} + // GlobalHTTP is a global HTTP module for a k6 instance/test run type GlobalHTTP struct{} -var _ modules.HasModuleInstancePerVU = new(GlobalHTTP) - // NewModuleInstancePerVU returns an HTTP instance for each VU func (g *GlobalHTTP) NewModuleInstancePerVU() interface{} { // this here needs to return interface{} return &HTTP{ // change the below fields to be not writable or not fields diff --git a/js/modules/k6/k6.go b/js/modules/k6/k6.go index 0f9eb1122eb..9aadcf7d75d 100644 --- a/js/modules/k6/k6.go +++ b/js/modules/k6/k6.go @@ -30,16 +30,11 @@ import ( "github.com/dop251/goja" "github.com/loadimpact/k6/js/common" - "github.com/loadimpact/k6/js/internal/modules" "github.com/loadimpact/k6/lib" "github.com/loadimpact/k6/lib/metrics" "github.com/loadimpact/k6/stats" ) -func init() { - modules.Register("k6", New()) -} - type K6 struct{} // ErrGroupInInitContext is returned when group() are using in the init context diff --git a/js/modules/k6/metrics/metrics.go b/js/modules/k6/metrics/metrics.go index 9f3d3cc491e..de4e51a3991 100644 --- a/js/modules/k6/metrics/metrics.go +++ b/js/modules/k6/metrics/metrics.go @@ -30,15 +30,10 @@ import ( "github.com/dop251/goja" "github.com/loadimpact/k6/js/common" - "github.com/loadimpact/k6/js/internal/modules" "github.com/loadimpact/k6/lib" "github.com/loadimpact/k6/stats" ) -func init() { - modules.Register("k6/metrics", New()) -} - var nameRegexString = "^[\\p{L}\\p{N}\\._ !\\?/&#\\(\\)<>%-]{1,128}$" var compileNameRegex = regexp.MustCompile(nameRegexString) @@ -59,7 +54,7 @@ func newMetric(ctxPtr *context.Context, name string, t stats.MetricType, isTime return nil, errors.New("metrics must be declared in the init context") } - //TODO: move verification outside the JS + // TODO: move verification outside the JS if !checkName(name) { return nil, common.NewInitContextError(fmt.Sprintf("Invalid metric name: '%s'", name)) } diff --git a/js/modules/k6/ws/ws.go b/js/modules/k6/ws/ws.go index 8a54be1fb75..e19813897bc 100644 --- a/js/modules/k6/ws/ws.go +++ b/js/modules/k6/ws/ws.go @@ -37,16 +37,11 @@ import ( "github.com/gorilla/websocket" "github.com/loadimpact/k6/js/common" - "github.com/loadimpact/k6/js/internal/modules" "github.com/loadimpact/k6/lib" "github.com/loadimpact/k6/lib/metrics" "github.com/loadimpact/k6/stats" ) -func init() { - modules.Register("k6/ws", New()) -} - // ErrWSInInitContext is returned when websockets are using in the init context var ErrWSInInitContext = common.NewInitContextError("using websockets in the init context is not supported") diff --git a/js/modules/modules.go b/js/modules/modules.go index fa7ebdd9fa6..c65f4fbae74 100644 --- a/js/modules/modules.go +++ b/js/modules/modules.go @@ -23,16 +23,27 @@ package modules import ( "fmt" "strings" + "sync" - "github.com/loadimpact/k6/js/internal/modules" + "github.com/loadimpact/k6/js/modules/k6" + "github.com/loadimpact/k6/js/modules/k6/crypto" + "github.com/loadimpact/k6/js/modules/k6/crypto/x509" + "github.com/loadimpact/k6/js/modules/k6/data" + "github.com/loadimpact/k6/js/modules/k6/encoding" + "github.com/loadimpact/k6/js/modules/k6/grpc" + "github.com/loadimpact/k6/js/modules/k6/html" + "github.com/loadimpact/k6/js/modules/k6/http" + "github.com/loadimpact/k6/js/modules/k6/metrics" + "github.com/loadimpact/k6/js/modules/k6/ws" ) const extPrefix string = "k6/x/" -// Get returns the module registered with name. -func Get(name string) interface{} { - return modules.Get(name) -} +//nolint:gochecknoglobals +var ( + modules = make(map[string]interface{}) + mx sync.RWMutex +) // Register the given mod as an external JavaScript module that can be imported // by name. The name must be unique across all registered modules and must be @@ -42,5 +53,44 @@ func Register(name string, mod interface{}) { panic(fmt.Errorf("external module names must be prefixed with '%s', tried to register: %s", extPrefix, name)) } - modules.Register(name, mod) + mx.Lock() + defer mx.Unlock() + + if _, ok := modules[name]; ok { + panic(fmt.Sprintf("module already registered: %s", name)) + } + modules[name] = mod +} + +// HasModuleInstancePerVU should be implemented by all native Golang modules that +// would require per-VU state. k6 will call their NewModuleInstancePerVU() methods +// every time a VU imports the module and use its result as the returned object. +type HasModuleInstancePerVU interface { + NewModuleInstancePerVU() interface{} +} + +// checks that modules implement HasModuleInstancePerVU +// this is done here as otherwise there will be a loop if the module imports this package +var _ HasModuleInstancePerVU = http.New() + +// GetJSModules returns a map of all js modules +func GetJSModules() map[string]interface{} { + result := map[string]interface{}{ + "k6": k6.New(), + "k6/crypto": crypto.New(), + "k6/crypto/x509": x509.New(), + "k6/data": data.New(), + "k6/encoding": encoding.New(), + "k6/net/grpc": grpc.New(), + "k6/html": html.New(), + "k6/http": http.New(), + "k6/metrics": metrics.New(), + "k6/ws": ws.New(), + } + + for name, module := range modules { + result[name] = module + } + + return result }