From 98eaa6af2101a9094d8a2ac51b040fc53b19eb60 Mon Sep 17 00:00:00 2001 From: Nicklas Lundin Date: Tue, 14 Apr 2026 11:59:10 +0200 Subject: [PATCH 1/2] fix: free host call request in wasm-msg guest side call_sync_host allocates a request in WASM memory and passes it to the host function, but never frees it. This leaks ~20 bytes per resolve_flags/apply_flags call, causing unbounded memory growth. Fix by freeing input_ptr in call_sync_host after the host returns, mirroring how call_sync_guest already frees its input via consume_request. This is a single fix that covers all host functions (current_time, log_message) across all provider languages. Java's consumeRequest is changed to read-only (no free) to avoid double-free since the guest now owns deallocation. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../assets/confidence_resolver.wasm | Bin 481211 -> 481221 bytes .../local_resolver/wasm_memory_test.go | 67 +++++++++++++++ .../confidence/sdk/WasmLocalResolver.java | 10 ++- .../confidence/sdk/WasmMemoryLeakTest.java | 77 ++++++++++++++++++ .../js/src/WasmResolver.memory.test.ts | 48 +++++++++++ .../python/tests/test_wasm_resolver.py | 49 ++++++++--- wasm-msg/src/sync.rs | 3 + 7 files changed, 240 insertions(+), 14 deletions(-) create mode 100644 openfeature-provider/go/confidence/internal/local_resolver/wasm_memory_test.go create mode 100644 openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/WasmMemoryLeakTest.java create mode 100644 openfeature-provider/js/src/WasmResolver.memory.test.ts diff --git a/openfeature-provider/go/confidence/internal/local_resolver/assets/confidence_resolver.wasm b/openfeature-provider/go/confidence/internal/local_resolver/assets/confidence_resolver.wasm index 13ca2da243c61f9ce6f71b0c06a91e63b3bd6aa2..8394c0c17fe22951681cb131f3249e2bdf7fa163 100755 GIT binary patch delta 81 zcmdnJUiRpE*$w+S7$QyW4$t WGXgOa5HkZY3lOtz_ub8wHwOUjb{)3> delta 71 zcmX@QUUv6-*$w+S7&|u~;JCbzv3>HCUCV@-9AHp^NuU7;7?hgBcejV{W&~m;AZ7+) O79eKb9=@9`Zw>(H%NtVw diff --git a/openfeature-provider/go/confidence/internal/local_resolver/wasm_memory_test.go b/openfeature-provider/go/confidence/internal/local_resolver/wasm_memory_test.go new file mode 100644 index 00000000..fa567c7c --- /dev/null +++ b/openfeature-provider/go/confidence/internal/local_resolver/wasm_memory_test.go @@ -0,0 +1,67 @@ +package local_resolver + +import ( + "context" + "testing" + + "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/wasm" + tu "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/testutil" +) + +// TestWasmMemoryStableOnRepeatedResolveCalls verifies that WASM linear memory +// does not grow unboundedly when resolving flags repeatedly. Each resolve_flags +// call triggers a current_time() host call from the guest. If the host function +// does not free the guest's request allocation, memory leaks accumulate and +// eventually force WASM memory.grow. +func TestWasmMemoryStableOnRepeatedResolveCalls(t *testing.T) { + factory := NewWasmResolverFactory(NoOpLogSink) + defer factory.Close(context.Background()) + + resolver := factory.New() + defer resolver.Close(context.Background()) + + wasmResolver := resolver.(*WasmResolver) + + testState := tu.LoadTestResolverState(t) + testAcctID := tu.LoadTestAccountID(t) + + if err := wasmResolver.SetResolverState(&wasm.SetResolverStateRequest{ + State: testState, + AccountId: testAcctID, + }); err != nil { + t.Fatalf("Failed to set resolver state: %v", err) + } + + request := tu.CreateResolveProcessRequest(tu.CreateTutorialFeatureRequest()) + + // Warm up: let allocator settling and one-time growth complete. + for i := 0; i < 50_000; i++ { + if _, err := wasmResolver.ResolveProcess(request); err != nil { + t.Fatalf("ResolveProcess failed during warmup: %v", err) + } + if i%1000 == 0 { + wasmResolver.FlushAllLogs() + } + } + + memBefore := wasmResolver.instance.Memory().Size() + + // Run resolves. Each call triggers current_time() in the guest which + // allocates a request in WASM memory. A leak here causes linear growth. + iterations := 50_000 + for i := 0; i < iterations; i++ { + if _, err := wasmResolver.ResolveProcess(request); err != nil { + t.Fatalf("ResolveProcess failed at iteration %d: %v", i, err) + } + if i%1000 == 0 { + wasmResolver.FlushAllLogs() + } + } + + memAfter := wasmResolver.instance.Memory().Size() + + if memAfter > memBefore { + t.Errorf("WASM memory grew from %d to %d bytes (%d bytes / %d pages) after %d resolve calls — indicates a leak in host function memory management", + memBefore, memAfter, memAfter-memBefore, (memAfter-memBefore)/65536, iterations) + } +} diff --git a/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/WasmLocalResolver.java b/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/WasmLocalResolver.java index de764c51..f20a154b 100644 --- a/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/WasmLocalResolver.java +++ b/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/WasmLocalResolver.java @@ -284,7 +284,9 @@ private T consumeResponse(int addr, ParserFn codec) { private T consumeRequest(int addr, ParserFn codec) { try { - final Messages.Request request = Messages.Request.parseFrom(consume(addr)); + // Read without freeing — the WASM guest frees its own request allocation + // in call_sync_host after the host function returns. + final Messages.Request request = Messages.Request.parseFrom(readBytes(addr)); return codec.apply(request.getData().toByteArray()); } catch (InvalidProtocolBufferException e) { throw new RuntimeException(e); @@ -309,6 +311,12 @@ private int transferResponseError(String error) { return transfer(wrapperBytes); } + private byte[] readBytes(int addr) { + final Memory mem = instance.memory(); + final int len = (int) (mem.readU32(addr - 4) - 4L); + return mem.readBytes(addr, len); + } + private byte[] consume(int addr) { final Memory mem = instance.memory(); final int len = (int) (mem.readU32(addr - 4) - 4L); diff --git a/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/WasmMemoryLeakTest.java b/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/WasmMemoryLeakTest.java new file mode 100644 index 00000000..7982a1e6 --- /dev/null +++ b/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/WasmMemoryLeakTest.java @@ -0,0 +1,77 @@ +package com.spotify.confidence.sdk; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.dylibso.chicory.runtime.Instance; +import com.google.protobuf.Struct; +import com.google.protobuf.util.Structs; +import com.google.protobuf.util.Values; +import com.spotify.confidence.sdk.flags.resolver.v1.ResolveFlagsRequest; +import com.spotify.confidence.sdk.flags.resolver.v1.ResolveProcessRequest; +import java.lang.reflect.Field; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * Regression test for WASM memory leak in host functions. Each resolve_flags call triggers a + * current_time() host call which allocates a request in WASM memory. If the host doesn't free it, + * memory leaks ~20 bytes per call and eventually forces memory.grow. + */ +class WasmMemoryLeakTest { + + private static int getWasmMemoryPages(WasmLocalResolver resolver) { + try { + Field instanceField = WasmLocalResolver.class.getDeclaredField("instance"); + instanceField.setAccessible(true); + Instance instance = (Instance) instanceField.get(resolver); + return instance.memory().pages(); + } catch (ReflectiveOperationException e) { + throw new RuntimeException("Failed to access WASM memory via reflection", e); + } + } + + @Test + void wasmMemoryStableOnRepeatedResolveCalls() { + WasmLocalResolver resolver = new WasmLocalResolver(request -> {}); + resolver.setResolverState(ResolveTest.exampleStateBytes, "account", null); + + ResolveProcessRequest request = + ResolveProcessRequest.newBuilder() + .setDeferredMaterializations( + ResolveFlagsRequest.newBuilder() + .addAllFlags(List.of("flags/flag-1")) + .setClientSecret(ResolveTest.secret.getSecret()) + .setEvaluationContext( + Structs.of( + "targeting_key", + Values.of("user-123"), + "bar", + Values.of(Struct.newBuilder().build()))) + .setApply(true) + .build()) + .build(); + + // Warm up to settle one-time allocations + for (int i = 0; i < 50_000; i++) { + resolver.resolveProcess(request).toCompletableFuture().join(); + if (i % 1000 == 0) resolver.flushAllLogs(); + } + + int pagesBefore = getWasmMemoryPages(resolver); + + for (int i = 0; i < 50_000; i++) { + resolver.resolveProcess(request).toCompletableFuture().join(); + if (i % 1000 == 0) resolver.flushAllLogs(); + } + + int pagesAfter = getWasmMemoryPages(resolver); + + assertEquals( + pagesBefore, + pagesAfter, + String.format( + "WASM memory grew from %d to %d pages (%d bytes leaked) — " + + "host function is not freeing guest request allocations", + pagesBefore, pagesAfter, (pagesAfter - pagesBefore) * 65536L)); + } +} diff --git a/openfeature-provider/js/src/WasmResolver.memory.test.ts b/openfeature-provider/js/src/WasmResolver.memory.test.ts new file mode 100644 index 00000000..de75b43e --- /dev/null +++ b/openfeature-provider/js/src/WasmResolver.memory.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest'; +import { UnsafeWasmResolver } from './WasmResolver'; +import { readFileSync } from 'node:fs'; +import { ResolveProcessRequest } from './proto/confidence/wasm/wasm_api'; + +const moduleBytes = readFileSync(__dirname + '/../../../wasm/confidence_resolver.wasm'); +const stateBytes = readFileSync(__dirname + '/../../../wasm/resolver_state.pb'); + +const module = new WebAssembly.Module(moduleBytes); +const CLIENT_SECRET = 'mkjJruAATQWjeY7foFIWfVAcBWnci2YF'; + +const RESOLVE_REQUEST: ResolveProcessRequest = { + deferredMaterializations: { + flags: ['flags/tutorial-feature'], + clientSecret: CLIENT_SECRET, + apply: true, + evaluationContext: { + targeting_key: 'tutorial_visitor', + visitor_id: 'tutorial_visitor', + }, + }, +}; + +const SET_STATE_REQUEST = { state: stateBytes, accountId: 'confidence-test' }; + +describe('wasm memory stability', () => { + it('should not leak memory on repeated resolve calls', () => { + const resolver = new UnsafeWasmResolver(module); + resolver.setResolverState(SET_STATE_REQUEST); + + // Warm up to settle one-time allocations + for (let i = 0; i < 50_000; i++) { + resolver.resolveProcess(RESOLVE_REQUEST); + if (i % 1000 === 0) resolver.flushLogs(); + } + + const memBefore = (resolver as any).exports.memory.buffer.byteLength; + + for (let i = 0; i < 50_000; i++) { + resolver.resolveProcess(RESOLVE_REQUEST); + if (i % 1000 === 0) resolver.flushLogs(); + } + + const memAfter = (resolver as any).exports.memory.buffer.byteLength; + + expect(memAfter).toBe(memBefore); + }); +}); diff --git a/openfeature-provider/python/tests/test_wasm_resolver.py b/openfeature-provider/python/tests/test_wasm_resolver.py index 6393db36..cd2070d8 100644 --- a/openfeature-provider/python/tests/test_wasm_resolver.py +++ b/openfeature-provider/python/tests/test_wasm_resolver.py @@ -162,6 +162,18 @@ def test_flush_assigned_returns_bytes( class TestMemoryManagement: """Test memory allocation and deallocation.""" + @staticmethod + def _build_request(client_secret: str) -> wasm_api_pb2.ResolveProcessRequest: + resolve_request = api_pb2.ResolveFlagsRequest() + resolve_request.flags.append(TEST_FLAG_NAME) + resolve_request.client_secret = client_secret + evaluation_context = struct_pb2.Struct() + evaluation_context.fields["targeting_key"].string_value = "user-123" + resolve_request.evaluation_context.CopyFrom(evaluation_context) + request = wasm_api_pb2.ResolveProcessRequest() + request.deferred_materializations.CopyFrom(resolve_request) + return request + def test_multiple_resolves_dont_leak_memory( self, wasm_bytes: bytes, @@ -169,22 +181,33 @@ def test_multiple_resolves_dont_leak_memory( test_account_id: str, test_client_secret: str, ) -> None: - """Multiple resolves don't cause memory issues.""" + """WASM memory must not grow on repeated resolves. + + Each resolve_flags call triggers a current_time() host call which + allocates a request in WASM memory. If the host doesn't free it, + memory leaks ~20 bytes per call and eventually forces memory.grow. + """ resolver = WasmResolver(wasm_bytes) resolver.set_resolver_state(test_resolver_state, test_account_id) + request = self._build_request(test_client_secret) + + # Warm up to settle one-time allocations + for i in range(50_000): + resolver.resolve_process(request) + if i % 1000 == 0: + resolver.flush_logs() - for i in range(100): - resolve_request = api_pb2.ResolveFlagsRequest() - resolve_request.flags.append(TEST_FLAG_NAME) - resolve_request.client_secret = test_client_secret - evaluation_context = struct_pb2.Struct() - evaluation_context.fields["targeting_key"].string_value = f"user-{i}" - resolve_request.evaluation_context.CopyFrom(evaluation_context) + pages_before = resolver._memory.size(resolver._store) - request = wasm_api_pb2.ResolveProcessRequest() - request.deferred_materializations.CopyFrom(resolve_request) + for i in range(50_000): resolver.resolve_process(request) + if i % 1000 == 0: + resolver.flush_logs() - # Should complete without issues - logs = resolver.flush_logs() - assert isinstance(logs, bytes) + pages_after = resolver._memory.size(resolver._store) + + assert pages_after == pages_before, ( + f"WASM memory grew from {pages_before} to {pages_after} pages " + f"({(pages_after - pages_before) * 65536} bytes leaked) — " + f"host function is not freeing guest request allocations" + ) diff --git a/wasm-msg/src/sync.rs b/wasm-msg/src/sync.rs index 3cd49b7a..1fb31414 100644 --- a/wasm-msg/src/sync.rs +++ b/wasm-msg/src/sync.rs @@ -27,6 +27,9 @@ where { let input_ptr = message::transfer_request(request); let output_ptr = unsafe { host_func(input_ptr) }; + // Free the request we allocated — the host has already read it. + // This mirrors call_sync_guest which frees its input via consume_request. + crate::memory::wasm_msg_free(input_ptr); if output_ptr.is_null() { return Err(String::from("Host function returned null pointer")); } From 02695862d226d90f452956fa0a72efde5dcd59d5 Mon Sep 17 00:00:00 2001 From: Nicklas Lundin Date: Tue, 14 Apr 2026 12:01:08 +0200 Subject: [PATCH 2/2] test: increase timeout for JS WASM memory test Co-Authored-By: Claude Opus 4.6 (1M context) --- openfeature-provider/js/src/WasmResolver.memory.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openfeature-provider/js/src/WasmResolver.memory.test.ts b/openfeature-provider/js/src/WasmResolver.memory.test.ts index de75b43e..b7a82e5b 100644 --- a/openfeature-provider/js/src/WasmResolver.memory.test.ts +++ b/openfeature-provider/js/src/WasmResolver.memory.test.ts @@ -24,7 +24,7 @@ const RESOLVE_REQUEST: ResolveProcessRequest = { const SET_STATE_REQUEST = { state: stateBytes, accountId: 'confidence-test' }; describe('wasm memory stability', () => { - it('should not leak memory on repeated resolve calls', () => { + it('should not leak memory on repeated resolve calls', { timeout: 30_000 }, () => { const resolver = new UnsafeWasmResolver(module); resolver.setResolverState(SET_STATE_REQUEST);