From 78f9f95c3be48315696cfca7199d245099c74431 Mon Sep 17 00:00:00 2001 From: Alberto Sala Date: Mon, 20 Jul 2026 17:46:51 +0200 Subject: [PATCH 1/2] Update wasmtime version from v1.0.0 to v42.0.0; added conf var for setting max memory used by a guest app --- NOTICES | 2 +- app/simple/Makefile | 4 +- app/trigger/Makefile | 4 +- cmd/executor/main.go | 3 + dockerfiles/.env.template | 2 + dockerfiles/docker-compose.yml | 1 + go.mod | 2 +- go.sum | 4 +- pkg/common/config.go | 5 +- pkg/common/config_test.go | 44 ++++++++++ pkg/executor/config.go | 21 +++++ pkg/executor/config_test.go | 67 +++++++++++++++ pkg/testutil/fullstack/cgo_ldflags.go | 2 +- pkg/wasm/wasmtime_runtime.go | 114 +++++++++++++++++++++----- pkg/wasm/wasmtime_runtime_test.go | 70 +++++++++++++++- 15 files changed, 312 insertions(+), 33 deletions(-) create mode 100644 pkg/common/config_test.go diff --git a/NOTICES b/NOTICES index d4264c9d..05ba997e 100644 --- a/NOTICES +++ b/NOTICES @@ -40,7 +40,7 @@ PERMISSIVE DEPENDENCIES - Go (Direct) ================================================================================ wasmtime-go - Version: v1.0.0 + Version: v42.0.0 License: Apache-2.0 WITH LLVM-exception Source: https://github.com/bytecodealliance/wasmtime-go Notice: Copyright The Bytecode Alliance contributors. diff --git a/app/simple/Makefile b/app/simple/Makefile index 7e43dbd5..23a12985 100644 --- a/app/simple/Makefile +++ b/app/simple/Makefile @@ -34,13 +34,13 @@ clean: $(OUT): $(SRC) @mkdir -p $(@D) @echo "Building development WASM module..." - tinygo build -o $@ -target=wasi . + tinygo build -o $@ -target=wasip1 . @ls -lh $@ | awk '{print "Binary size: " $$5}' $(PROD_OUT): $(SRC) @mkdir -p $(@D) @echo "Building production WASM module..." - tinygo build -o $@ -opt=s -no-debug -target=wasi . + tinygo build -o $@ -opt=s -no-debug -target=wasip1 . @ls -lh $@ | awk '{print "Binary size: " $$5}' wat: $(WAT) diff --git a/app/trigger/Makefile b/app/trigger/Makefile index c8ce97b2..e2aca721 100644 --- a/app/trigger/Makefile +++ b/app/trigger/Makefile @@ -20,13 +20,13 @@ clean: $(OUT): $(SRC) @mkdir -p $(@D) @echo "Building development WASM module..." - tinygo build -o $@ -target=wasi . + tinygo build -o $@ -target=wasip1 . @ls -lh $@ | awk '{print "Binary size: " $$5}' $(PROD_OUT): $(SRC) @mkdir -p $(@D) @echo "Building production WASM module..." - tinygo build -o $@ -opt=s -no-debug -target=wasi . + tinygo build -o $@ -opt=s -no-debug -target=wasip1 . @ls -lh $@ | awk '{print "Binary size: " $$5}' wat: $(WAT) diff --git a/cmd/executor/main.go b/cmd/executor/main.go index 8a05a36c..675ea65e 100644 --- a/cmd/executor/main.go +++ b/cmd/executor/main.go @@ -61,6 +61,9 @@ func main() { // Create the WASM runtime runtime := wasm.NewWasmtimeRuntime(log, config.MaxCachedModules) + if config.MaxGuestMemoryBytes > 0 { + runtime.SetMaxGuestMemoryBytes(config.MaxGuestMemoryBytes) + } // Create the appropriate server based on configuration var server communication.ExecutorServer diff --git a/dockerfiles/.env.template b/dockerfiles/.env.template index 643969c3..bef4e273 100644 --- a/dockerfiles/.env.template +++ b/dockerfiles/.env.template @@ -17,6 +17,8 @@ EXECUTOR_PORT='4000' EXECUTOR_FUEL_PRICE_PER_UNIT='1' EXECUTOR_MIN_FEE_PER_REQUEST='10' EXECUTOR_MAX_CACHED_MODULES='0' +#max linear memory per WASM guest in bytes; 0 = default (2 GiB, also the maximum). Worst-case RAM is roughly EXECUTOR_MAX_CACHED_MODULES * this value. +EXECUTOR_MAX_GUEST_MEMORY_BYTES='0' EXECUTOR_KEYSET_RECOVERY_TYPE='1' EXECUTOR_KMS_KEY_ARN='arn:aws:kms:eu-west-1:215705706013:key/7be0593e-72e9-46f2-9a9f-9a85a0e719e4' EXECUTOR_KMS_REGION='eu-west-1' diff --git a/dockerfiles/docker-compose.yml b/dockerfiles/docker-compose.yml index 8fa2600a..9401d2b9 100644 --- a/dockerfiles/docker-compose.yml +++ b/dockerfiles/docker-compose.yml @@ -63,6 +63,7 @@ services: - EXECUTOR_FUEL_PRICE_PER_UNIT - EXECUTOR_MIN_FEE_PER_REQUEST - EXECUTOR_MAX_CACHED_MODULES + - EXECUTOR_MAX_GUEST_MEMORY_BYTES - EXECUTOR_LOG_KIND - EXECUTOR_LOG_CONSOLE - EXECUTOR_LOG_CONSOLE_LEVEL diff --git a/go.mod b/go.mod index 6b82a299..089c6333 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,6 @@ require ( github.com/aws/aws-sdk-go-v2 v1.41.1 github.com/aws/aws-sdk-go-v2/config v1.29.14 github.com/aws/aws-sdk-go-v2/service/kms v1.49.5 - github.com/bytecodealliance/wasmtime-go v1.0.0 github.com/elliotchance/orderedmap/v3 v3.1.0 github.com/ethereum/go-ethereum v1.17.2 github.com/fxamacker/cbor/v2 v2.2.0 @@ -58,6 +57,7 @@ require ( github.com/VictoriaMetrics/fastcache v1.13.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bits-and-blooms/bitset v1.20.0 // indirect + github.com/bytecodealliance/wasmtime-go/v42 v42.0.0 github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cockroachdb/errors v1.11.3 // indirect github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce // indirect diff --git a/go.sum b/go.sum index 2b4fe616..15aada34 100644 --- a/go.sum +++ b/go.sum @@ -44,8 +44,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bits-and-blooms/bitset v1.20.0 h1:2F+rfL86jE2d/bmw7OhqUg2Sj/1rURkBn3MdfoPyRVU= github.com/bits-and-blooms/bitset v1.20.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= -github.com/bytecodealliance/wasmtime-go v1.0.0 h1:9u9gqaUiaJeN5IoD1L7egD8atOnTGyJcNp8BhkL9cUU= -github.com/bytecodealliance/wasmtime-go v1.0.0/go.mod h1:jjlqQbWUfVSbehpErw3UoWFndBXRRMvfikYH6KsCwOg= +github.com/bytecodealliance/wasmtime-go/v42 v42.0.0 h1:pJMf9xmpx67KdfVuSv9rZQcwnIISO9EsAGeFTdGsnk4= +github.com/bytecodealliance/wasmtime-go/v42 v42.0.0/go.mod h1:uhQcMe9gNDkAeA1MEo0KDcI/QppEtP3zeFiZXaf1LSI= github.com/cespare/cp v0.1.0 h1:SE+dxFebS7Iik5LK0tsi1k9ZCxEaFX4AjQmoyA+1dJk= github.com/cespare/cp v0.1.0/go.mod h1:SOGHArjBr4JWaSDEVpWpo/hNg6RoKrls6Oh40hiwW+s= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= diff --git a/pkg/common/config.go b/pkg/common/config.go index 7a6e3b41..a53a8fd2 100644 --- a/pkg/common/config.go +++ b/pkg/common/config.go @@ -32,7 +32,10 @@ func GetConfigVarInt64(name string, defaultValue int64, fileProperties *properti if confVar == "" { return defaultValue } else { - var parsed, err = strconv.ParseInt(confVar, 10, 32) + // Parse the full int64 range: values above 2^31 must reach the caller + // (e.g. so config validation can reject an out-of-range value) instead + // of silently falling back to the default. + var parsed, err = strconv.ParseInt(confVar, 10, 64) if err != nil { fmt.Printf("Failed to convert %v for error %v, using default value\n", name, err) return defaultValue diff --git a/pkg/common/config_test.go b/pkg/common/config_test.go new file mode 100644 index 00000000..b397fc95 --- /dev/null +++ b/pkg/common/config_test.go @@ -0,0 +1,44 @@ +package common + +import ( + "testing" + + "github.com/magiconair/properties" + "github.com/stretchr/testify/require" +) + +func TestGetConfigVarInt64(t *testing.T) { + props := properties.NewProperties() + require.NoError(t, props.SetValue("FROM_FILE", 42)) + require.NoError(t, props.SetValue("ABOVE_INT32", int64(3221225472))) // 3 GiB, > math.MaxInt32 + require.NoError(t, props.SetValue("NEGATIVE", -7)) + props.MustSet("GARBAGE", "not-a-number") + + t.Run("missing var returns default", func(t *testing.T) { + require.Equal(t, int64(5), GetConfigVarInt64("MISSING", 5, props)) + }) + + t.Run("file value is parsed", func(t *testing.T) { + require.Equal(t, int64(42), GetConfigVarInt64("FROM_FILE", 5, props)) + }) + + t.Run("env overrides file", func(t *testing.T) { + t.Setenv("FROM_FILE", "43") + require.Equal(t, int64(43), GetConfigVarInt64("FROM_FILE", 5, props)) + }) + + t.Run("values above int32 range are parsed, not defaulted", func(t *testing.T) { + // Regression: ParseInt used bitSize 32, silently turning any value + // above 2^31-1 (e.g. EXECUTOR_MAX_GUEST_MEMORY_BYTES=3GiB) into the + // default so validation never saw it. + require.Equal(t, int64(3221225472), GetConfigVarInt64("ABOVE_INT32", 0, props)) + }) + + t.Run("negative values are parsed", func(t *testing.T) { + require.Equal(t, int64(-7), GetConfigVarInt64("NEGATIVE", 5, props)) + }) + + t.Run("unparseable value returns default", func(t *testing.T) { + require.Equal(t, int64(5), GetConfigVarInt64("GARBAGE", 5, props)) + }) +} diff --git a/pkg/executor/config.go b/pkg/executor/config.go index 951047c5..bbf31d72 100644 --- a/pkg/executor/config.go +++ b/pkg/executor/config.go @@ -65,6 +65,13 @@ type Config struct { // MaxCachedModules is the maximum number of WASM modules to keep in the LRU cache. // 0 means unlimited. MaxCachedModules int + + // MaxGuestMemoryBytes caps the linear memory a single WASM guest can grow to. + // 0 means the 2 GiB default, which is also the maximum allowed value (the + // host ABI exchanges guest pointers as signed 32-bit offsets). Note that the + // worst-case enclave RAM usage is roughly MaxCachedModules * MaxGuestMemoryBytes, + // so size the two together against the enclave memory budget. + MaxGuestMemoryBytes int64 } const confFileName = "executor.conf" @@ -132,6 +139,7 @@ func LoadConfig() (*Config, error) { LogNetworkLevel: common.GetConfigVar("EXECUTOR_LOG_NETWORK_LEVEL", "info", fileProperties), CommunicationParams: communicationParams, MaxCachedModules: int(common.GetConfigVarInt64("EXECUTOR_MAX_CACHED_MODULES", 0, fileProperties)), + MaxGuestMemoryBytes: common.GetConfigVarInt64("EXECUTOR_MAX_GUEST_MEMORY_BYTES", 0, fileProperties), }, nil } @@ -180,6 +188,19 @@ func (c *Config) Validate() error { c.CommunicationParams.RequestTimeoutSec)) } + // --- Guest memory cap --- + // The WASM host ABI exchanges guest pointers as signed 32-bit offsets, so a + // guest may never grow past 2 GiB (see pkg/wasm maxGuestMemoryCeilingBytes — + // duplicated here to avoid linking libwasmtime into every pkg/executor + // consumer). 0 selects the 2 GiB default; anything else must be in range + // rather than silently clamped, so a misconfigured operator finds out at startup. + const maxGuestMemoryCeilingBytes = 2 << 30 + if c.MaxGuestMemoryBytes < 0 || c.MaxGuestMemoryBytes > maxGuestMemoryCeilingBytes { + errs = append(errs, fmt.Sprintf( + "EXECUTOR_MAX_GUEST_MEMORY_BYTES must be between 0 (default: 2 GiB) and %d (2 GiB), got %d", + int64(maxGuestMemoryCeilingBytes), c.MaxGuestMemoryBytes)) + } + // --- KMS configuration --- errs = append(errs, c.validateKMSConfig()...) diff --git a/pkg/executor/config_test.go b/pkg/executor/config_test.go index 7ea0b66f..4230104b 100644 --- a/pkg/executor/config_test.go +++ b/pkg/executor/config_test.go @@ -2,6 +2,7 @@ package executor import ( "math/big" + "os" "testing" "github.com/HorizenOfficial/vela/pkg/common" @@ -168,3 +169,69 @@ func TestValidate_MultipleErrors(t *testing.T) { assert.Contains(t, err.Error(), "EXECUTOR_FUEL_PRICE_PER_UNIT") assert.Contains(t, err.Error(), "EXECUTOR_COMMUNICATION_PARAMS_REQUEST_TIMEOUT_SEC") } + +func TestValidate_MaxGuestMemoryBytes_InRange_NoError(t *testing.T) { + cfg := validExecutorConfig() + cfg.MaxGuestMemoryBytes = 512 * 1024 * 1024 + require.NoError(t, cfg.Validate()) +} + +func TestValidate_MaxGuestMemoryBytes_Negative(t *testing.T) { + cfg := validExecutorConfig() + cfg.MaxGuestMemoryBytes = -1 + + err := cfg.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "EXECUTOR_MAX_GUEST_MEMORY_BYTES") +} + +func TestValidate_MaxGuestMemoryBytes_AboveCeiling(t *testing.T) { + cfg := validExecutorConfig() + cfg.MaxGuestMemoryBytes = (2 << 30) + 1 + + err := cfg.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "EXECUTOR_MAX_GUEST_MEMORY_BYTES") +} + +// loadConfigFromFile writes an executor.conf with the given content in a fresh +// temp working directory and runs LoadConfig against it, exercising the real +// file-parsing path (GetConfigVarInt64 etc.) rather than setting struct fields +// directly. +func loadConfigFromFile(t *testing.T, conf string) *Config { + t.Helper() + t.Chdir(t.TempDir()) + require.NoError(t, os.WriteFile(confFileName, []byte(conf), 0600)) + cfg, err := LoadConfig() + require.NoError(t, err) + return cfg +} + +func TestLoadConfig_MaxGuestMemoryBytes_AboveCeilingInConfFile_Rejected(t *testing.T) { + // Clear any ambient env overrides: empty env values fall through to the file. + t.Setenv("EXECUTOR_MAX_GUEST_MEMORY_BYTES", "") + t.Setenv("EXECUTOR_KEYSET_RECOVERY_TYPE", "") + + // 3 GiB — above the 2 GiB ceiling. The value must survive parsing intact + // (regression: a 32-bit ParseInt silently replaced it with the default, + // so Validate never saw it) and then be rejected by Validate. + cfg := loadConfigFromFile(t, + "EXECUTOR_KEYSET_RECOVERY_TYPE=0\nEXECUTOR_MAX_GUEST_MEMORY_BYTES=3221225472\n") + require.Equal(t, int64(3221225472), cfg.MaxGuestMemoryBytes) + + err := cfg.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "EXECUTOR_MAX_GUEST_MEMORY_BYTES") +} + +func TestLoadConfig_MaxGuestMemoryBytes_CeilingInConfFile_Accepted(t *testing.T) { + t.Setenv("EXECUTOR_MAX_GUEST_MEMORY_BYTES", "") + t.Setenv("EXECUTOR_KEYSET_RECOVERY_TYPE", "") + + // Exactly 2 GiB (2147483648) is the maximum legal value; it is above + // math.MaxInt32, so it also only works with a full 64-bit parse. + cfg := loadConfigFromFile(t, + "EXECUTOR_KEYSET_RECOVERY_TYPE=0\nEXECUTOR_MAX_GUEST_MEMORY_BYTES=2147483648\n") + require.Equal(t, int64(2147483648), cfg.MaxGuestMemoryBytes) + require.NoError(t, cfg.Validate()) +} diff --git a/pkg/testutil/fullstack/cgo_ldflags.go b/pkg/testutil/fullstack/cgo_ldflags.go index 10149a32..e3023ffb 100644 --- a/pkg/testutil/fullstack/cgo_ldflags.go +++ b/pkg/testutil/fullstack/cgo_ldflags.go @@ -1,7 +1,7 @@ package fullstack // Problem: -// wasmtime-go (v1.0.0) ships a pre-built libwasmtime.a that statically includes +// wasmtime-go ships a pre-built libwasmtime.a that statically includes // zstd C sources (used internally by wasmtime's compiled-module cache). Meanwhile, // go-ethereum transitively imports github.com/DataDog/zstd (v1.4.5), which compiles // the same zstd C library via cgo. When both end up in the same binary — as they do diff --git a/pkg/wasm/wasmtime_runtime.go b/pkg/wasm/wasmtime_runtime.go index da189dae..b40c67d6 100644 --- a/pkg/wasm/wasmtime_runtime.go +++ b/pkg/wasm/wasmtime_runtime.go @@ -21,13 +21,32 @@ import ( "github.com/HorizenOfficial/vela/pkg/common/apperrors" "github.com/HorizenOfficial/vela/pkg/logger" appCommon "github.com/HorizenOfficial/vela/pkg/wasm/common" - "github.com/bytecodealliance/wasmtime-go" + wasmtime "github.com/bytecodealliance/wasmtime-go/v42" ethCommon "github.com/ethereum/go-ethereum/common" ) // Address is a local definition of a 20-byte address. type Address [20]byte +// maxGuestMemoryCeilingBytes is both the default and the maximum allowed value +// for the per-guest linear memory cap. The runtime refuses growth beyond the +// cap (the guest sees a failed memory.grow), protecting the enclave's fixed +// RAM from a buggy or malicious app. The 2 GiB ceiling is a hard ABI +// constraint: the host exchanges guest pointers as signed int32 offsets (see +// writeToMemory and extractResultBytes), so no guest offset may reach 2 GiB. +const maxGuestMemoryCeilingBytes = 2 << 30 + +// newModuleStore creates a per-module store with the guest memory cap applied. +// Must be called with moduleLock held (write lock), which also guards +// maxGuestMemoryBytes. +func (r *WasmtimeRuntime) newModuleStore() *wasmtime.Store { + store := wasmtime.NewStore(r.engine) + // -1 keeps the wasmtime default for limits we don't want to override + // (table elements, instance/table/memory counts). + store.Limiter(r.maxGuestMemoryBytes, -1, -1, -1, -1) + return store +} + // ApplicationModule contains the compiled module, its instantiated instance, // the module-specific store, the exported memory, and convenience handles. // Note: a Store represents module execution state and is not safe sharing one store across modules @@ -50,6 +69,20 @@ func (m *ApplicationModule) Close() { m.cleanupFds() } + // Explicitly release the native wasmtime state instead of waiting for a + // Go GC finalizer pass: the Go GC does not see the weight of the guest + // linear memory held by the store, so relying on finalizers can keep + // hundreds of MB allocated inside the enclave long after eviction. + // Closing the store frees the instance, its memory and funcs; closing + // the module frees the compiled code. Both are safe to call once here + // because the runtime never uses an ApplicationModule after Close. + if m.store != nil { + m.store.Close() + } + if m.module != nil { + m.module.Close() + } + // Clear references to WASM resources m.instance = nil m.module = nil @@ -68,6 +101,9 @@ type WasmtimeRuntime struct { maxCachedModules int // 0 = unlimited accessOrder *list.List // LRU order: front = most recent, back = least recent accessElements map[common.ApplicationIdType]*list.Element // O(1) lookup into accessOrder + // maxGuestMemoryBytes caps the linear memory of each guest store created + // from now on; always in (0, maxGuestMemoryCeilingBytes]. Guarded by moduleLock. + maxGuestMemoryBytes int64 } // NewWasmtimeRuntime creates a new wasmtime runtime instance. @@ -79,12 +115,13 @@ func NewWasmtimeRuntime(log logger.Logger, maxCachedModules int) *WasmtimeRuntim engine := wasmtime.NewEngine() return &WasmtimeRuntime{ - engine: engine, - modules: make(map[common.ApplicationIdType]*ApplicationModule), - log: log, - maxCachedModules: maxCachedModules, - accessOrder: list.New(), - accessElements: make(map[common.ApplicationIdType]*list.Element), + engine: engine, + modules: make(map[common.ApplicationIdType]*ApplicationModule), + log: log, + maxCachedModules: maxCachedModules, + accessOrder: list.New(), + accessElements: make(map[common.ApplicationIdType]*list.Element), + maxGuestMemoryBytes: maxGuestMemoryCeilingBytes, } } @@ -172,11 +209,12 @@ func (r *WasmtimeRuntime) writeToMemory(module *ApplicationModule, data []byte) // Safely get a snapshot of the memory data and perform bounds checks memData := module.memory.UnsafeData(module.store) - // note: the wasmtime uses a doubling of the store size when more memory is required and the UnsafeData() panics if we use a 4GB size. - // As a consequence we can not have more than 2GB of memory allocated. - // In other words: When the internal store grows from 2 GB -> 4 GB, UnsafeData call suddenly fails, but we are not actually running - // out of Wasm memory, we are hitting a Go-side API limitation caused by Wasmtime’s internal buffer resize. - // As a side effect we also could safely use an int32 ptr as the offset (avoid using uint32 cast) + // note: guest memory is capped at <= maxGuestMemoryCeilingBytes (2 GiB) via the + // store's Limiter (see newModuleStore), so every guest offset fits in a positive + // int32 and we can use the returned ptr directly without an unsigned cast. + // (The wasmtime-go v1 limitation where UnsafeData panicked when memory reached + // 4 GiB is gone — v42 builds the slice with unsafe.Slice — the bound below is + // the deliberate cap, not an API workaround.) // Convert the signed int32 pointer to its true unsigned 32-bit value (uint32) // This correctly interprets the bits of an address as a large positive number. @@ -252,7 +290,7 @@ func (r *WasmtimeRuntime) loadModuleUnlocked(ctx context.Context, appId common.A } // Create a per-module store - store := wasmtime.NewStore(r.engine) + store := r.newModuleStore() cleanupLogPipes, err := r.configureWasiLogPipes(ctx, appId, store) if err != nil { return nil, big.NewInt(0), fmt.Errorf("failed to configure WASI log pipes: %w", err) @@ -390,7 +428,7 @@ func (r *WasmtimeRuntime) deployUnlocked(ctx context.Context, appId common.Appli } // Create a per-module store - store := wasmtime.NewStore(r.engine) + store := r.newModuleStore() cleanupLogPipes, err := r.configureWasiLogPipes(ctx, appId, store) if err != nil { return nil, big.NewInt(0), fmt.Errorf("failed to configure WASI log pipes: %w", err) @@ -859,6 +897,12 @@ func (r *WasmtimeRuntime) Close() error { r.accessOrder.Init() r.accessElements = make(map[common.ApplicationIdType]*list.Element) + // Deterministically free the engine's native state (compiled-code caches + // etc.). The engine is refcounted at the C level, so any store not tracked + // in r.modules keeps its share alive until it is closed or collected. + if r.engine != nil { + r.engine.Close() + } r.engine = nil r.log.Info("Wasmtime Runtime: Wasmtime runtime closed successfully") return nil @@ -910,6 +954,31 @@ func (r *WasmtimeRuntime) GetMaxCachedModules() int { return r.maxCachedModules } +// SetMaxGuestMemoryBytes updates the per-guest linear memory cap. The cap is +// applied to stores created from now on; modules already cached keep the cap +// they were created with. Values <= 0 or above the 2 GiB ABI ceiling are +// replaced with the ceiling (see maxGuestMemoryCeilingBytes) and reported. +func (r *WasmtimeRuntime) SetMaxGuestMemoryBytes(maxBytes int64) { + r.moduleLock.Lock() + defer r.moduleLock.Unlock() + + if maxBytes <= 0 || maxBytes > maxGuestMemoryCeilingBytes { + r.log.Warn("Runtime: requested guest memory cap %d out of range, using ceiling %d", maxBytes, int64(maxGuestMemoryCeilingBytes)) + maxBytes = maxGuestMemoryCeilingBytes + } + + r.log.Info("Runtime: Setting maxGuestMemoryBytes from %d to %d", r.maxGuestMemoryBytes, maxBytes) + r.maxGuestMemoryBytes = maxBytes +} + +// GetMaxGuestMemoryBytes returns the per-guest linear memory cap applied to +// newly created stores. +func (r *WasmtimeRuntime) GetMaxGuestMemoryBytes() int64 { + r.moduleLock.RLock() + defer r.moduleLock.RUnlock() + return r.maxGuestMemoryBytes +} + // ToWasmType converts ApplicationIdType (uint64) to the WASM i64 representation // (int64). WASM has no unsigned integer types — i64 carries 64 bits regardless of // signedness, so the bit pattern passes through unchanged. @@ -939,20 +1008,27 @@ func (r *WasmtimeRuntime) GetAllocatedMemoryStats(ctx context.Context, appId com // we allocate a buffer wher the results will be stored const resultSize = 2 * 8 // 2 int64 values, 8 bytes each - resultPtrValue, err := allocateFunc.Call(appModule.store, int32(resultSize)) + rawResultPtr, err := allocateFunc.Call(appModule.store, int32(resultSize)) if err != nil { return 0, 0, fmt.Errorf("failed to allocate memory for results: %w", err) } - // ensure we free the input buffer after allocate returns - if appModule.deallocate != nil && resultPtrValue != 0 { + resultPtr, err := toInt32(rawResultPtr) + if err != nil { + return 0, 0, fmt.Errorf("allocate returned unexpected value: %w", err) + } + if resultPtr == 0 { + return 0, 0, fmt.Errorf("allocate returned null pointer (0)") + } + + // ensure we free the result buffer on exit + if appModule.deallocate != nil { defer func() { - _, _ = appModule.deallocate.Call(appModule.store, resultPtrValue, resultSize) + _, _ = appModule.deallocate.Call(appModule.store, resultPtr, int32(resultSize)) }() } // according to ABI C interface, tinygo uses an inout ptr to store return values, even the signature is different - resultPtr := resultPtrValue.(int32) // Get the pointer/address _, err = statsFunc.Call(appModule.store, resultPtr) if err != nil { return 0, 0, fmt.Errorf("failed to call func: %w", err) diff --git a/pkg/wasm/wasmtime_runtime_test.go b/pkg/wasm/wasmtime_runtime_test.go index ba724ff7..e96d23f2 100644 --- a/pkg/wasm/wasmtime_runtime_test.go +++ b/pkg/wasm/wasmtime_runtime_test.go @@ -10,7 +10,7 @@ import ( "github.com/HorizenOfficial/vela/pkg/common" "github.com/HorizenOfficial/vela/pkg/logger" appCommon "github.com/HorizenOfficial/vela/pkg/wasm/common" - "github.com/bytecodealliance/wasmtime-go" + wasmtime "github.com/bytecodealliance/wasmtime-go/v42" ethCommon "github.com/ethereum/go-ethereum/common" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -127,7 +127,8 @@ func TestWriteToMemory_NilModuleStore(t *testing.T) { // Create a valid memory object, but instance can be nil // since it's not reached when data is empty. - memType := wasmtime.NewMemoryType(1, false, 0) + memType, err := wasmtime.NewMemoryType(1, false, 0, false) + require.NoError(t, err) memory, err := wasmtime.NewMemory(store, memType) require.NoError(t, err) @@ -161,7 +162,8 @@ func TestWriteToMemory_NilData(t *testing.T) { // Create a valid memory object, but instance can be nil // since it's not reached when data is empty. - memType := wasmtime.NewMemoryType(1, false, 0) + memType, err := wasmtime.NewMemoryType(1, false, 0, false) + require.NoError(t, err) memory, err := wasmtime.NewMemory(store, memType) require.NoError(t, err) @@ -191,7 +193,8 @@ func TestExtractResultBytes(t *testing.T) { // Setup a mock memory for testing, using the runtime's store // 1 is the minimum size in WebAssembly pages (WebAssembly page size = 64 KiB (65,536 bytes)) - memType := wasmtime.NewMemoryType(1, false, 0) + memType, err := wasmtime.NewMemoryType(1, false, 0, false) + require.NoError(t, err) memory, err := wasmtime.NewMemory(store, memType) require.NoError(t, err) @@ -452,3 +455,62 @@ func TestProcessRequest_DispatchesByRequestType(t *testing.T) { require.Nil(t, failure, "Process must succeed via process_request") require.Equal(t, []byte{1}, procState, "Process must dispatch to process_request (state=[1])") } + +func TestSetMaxGuestMemoryBytes(t *testing.T) { + runtime := NewWasmtimeRuntime(testLogger, 0) + defer runtime.Close() + + // The default is the 2 GiB ceiling + require.Equal(t, int64(maxGuestMemoryCeilingBytes), runtime.GetMaxGuestMemoryBytes()) + + // In-range values are applied as-is + runtime.SetMaxGuestMemoryBytes(512 * 1024 * 1024) + require.Equal(t, int64(512*1024*1024), runtime.GetMaxGuestMemoryBytes()) + + // Out-of-range values fall back to the ceiling + for _, invalid := range []int64{0, -1, maxGuestMemoryCeilingBytes + 1} { + runtime.SetMaxGuestMemoryBytes(invalid) + require.Equal(t, int64(maxGuestMemoryCeilingBytes), runtime.GetMaxGuestMemoryBytes()) + } +} + +// TestGuestMemoryCapEnforced verifies that stores created by the runtime refuse +// guest memory growth beyond the configured cap: memory.grow reports failure +// (-1) to the guest instead of growing. +func TestGuestMemoryCapEnforced(t *testing.T) { + runtime := NewWasmtimeRuntime(testLogger, 0) + defer runtime.Close() + + const pageSize = 64 * 1024 + runtime.SetMaxGuestMemoryBytes(2 * pageSize) + + wasmBytes, err := wasmtime.Wat2Wasm(`(module + (memory (export "memory") 1) + (func (export "grow") (param i32) (result i32) + local.get 0 + memory.grow))`) + require.NoError(t, err) + + module, err := wasmtime.NewModule(runtime.engine, wasmBytes) + require.NoError(t, err) + + runtime.moduleLock.Lock() + store := runtime.newModuleStore() + runtime.moduleLock.Unlock() + + instance, err := wasmtime.NewInstance(store, module, nil) + require.NoError(t, err) + + grow := instance.GetFunc(store, "grow") + require.NotNil(t, grow) + + // 1 -> 2 pages: within the cap, returns the previous size in pages + res, err := grow.Call(store, int32(1)) + require.NoError(t, err) + require.Equal(t, int32(1), res) + + // 2 -> 3 pages: beyond the cap, the guest sees a failed grow + res, err = grow.Call(store, int32(1)) + require.NoError(t, err) + require.Equal(t, int32(-1), res) +} From e01fb72b23ed8e95bf87cf31966f7c1d83ad182e Mon Sep 17 00:00:00 2001 From: Alberto Sala Date: Tue, 21 Jul 2026 10:24:06 +0200 Subject: [PATCH 2/2] Added a comment documenting a weakness to be handled when enabling concurrency --- pkg/wasm/wasmtime_runtime.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/pkg/wasm/wasmtime_runtime.go b/pkg/wasm/wasmtime_runtime.go index b40c67d6..d9e1eafc 100644 --- a/pkg/wasm/wasmtime_runtime.go +++ b/pkg/wasm/wasmtime_runtime.go @@ -878,6 +878,18 @@ func (r *WasmtimeRuntime) removeFromAccessOrder(appId common.ApplicationIdType) } // cleanupModule releases all resources associated with a single ApplicationModule. +// +// CONCURRENCY CAVEAT: module.Close() calls store.Close(), which frees the +// native wasmtime state immediately (rather than at a GC finalizer pass). This +// is only safe because the runtime is driven serially: callers such as Deposit +// and ProcessRequest fetch a module under moduleLock but then execute the guest +// call AFTER releasing the lock, so a concurrent load for a different appId +// could otherwise pick that same module as the LRU eviction victim and close +// its store out from under the in-flight call (use-after-free / panic). Before +// enabling concurrent execution (see the per-goroutine-instance TODOs in +// integration_test.go), eviction-close must be gated on the module not being in +// use — e.g. refcounting live calls or deferring Close until the last caller +// finishes. func (r *WasmtimeRuntime) cleanupModule(appId common.ApplicationIdType, module *ApplicationModule) { r.log.Info("Cleaning up module %d", appId) module.Close()