diff --git a/go.mod b/go.mod index 2efab79ac..e021b4266 100644 --- a/go.mod +++ b/go.mod @@ -66,7 +66,7 @@ require ( github.com/wippyai/tree-sitter-markdown v0.0.3 github.com/wippyai/tree-sitter-sql v0.0.4 github.com/wippyai/wapp v0.1.2 - github.com/wippyai/wasm-runtime v0.0.0-20260209224309-586ea6933075 + github.com/wippyai/wasm-runtime v0.0.0-20260619200926-cc678f6c6549 github.com/xuri/excelize/v2 v2.10.1 go.opentelemetry.io/otel v1.44.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.40.0 diff --git a/go.sum b/go.sum index 2b313100f..9026e648a 100644 --- a/go.sum +++ b/go.sum @@ -595,8 +595,8 @@ github.com/wippyai/tree-sitter-sql v0.0.4 h1:0hJyjkV6/lhoGA5FJAaf96od2xyo+OJINSE github.com/wippyai/tree-sitter-sql v0.0.4/go.mod h1:QN9CfIO55fwQNVNk1p/7pjxrLk7iKxrQs42Zh6lrr84= github.com/wippyai/wapp v0.1.2 h1:fCoxKr9s3gk+pWx4XcnIQmOVgPiuimXf3QcE9mHbdmw= github.com/wippyai/wapp v0.1.2/go.mod h1:ndCkYR80+osLGbd7AFWlP+3DxwooR+R6cxQYPZhksg4= -github.com/wippyai/wasm-runtime v0.0.0-20260209224309-586ea6933075 h1:TZZCgi+CTZKcHRhpziSl9t1T33Dq+VWBNZQ9w6e9b+M= -github.com/wippyai/wasm-runtime v0.0.0-20260209224309-586ea6933075/go.mod h1:1AVYdZibORqlBez081Seakxv2u9IR+KIMrvlBKsk2bQ= +github.com/wippyai/wasm-runtime v0.0.0-20260619200926-cc678f6c6549 h1:XLrkn5LIgjIIyJ2pJ7WnGYyn9vCfP1K252wCw67mgiw= +github.com/wippyai/wasm-runtime v0.0.0-20260619200926-cc678f6c6549/go.mod h1:1AVYdZibORqlBez081Seakxv2u9IR+KIMrvlBKsk2bQ= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= diff --git a/runtime/wasm/engine/process.go b/runtime/wasm/engine/process.go index 5e2e8136a..6ac48c062 100644 --- a/runtime/wasm/engine/process.go +++ b/runtime/wasm/engine/process.go @@ -23,6 +23,16 @@ import ( wasmrt "github.com/wippyai/wasm-runtime/runtime" ) +// recycleWarmInstanceAfter bounds how many synchronous calls a reused warm +// instance serves before it is recycled. WASM linear memory only ever grows, so a +// warm instance kept alive across calls accumulates each call's guest allocations; +// after this many calls the instance is closed so the next call instantiates a +// fresh one and reclaims the memory. Re-instantiation is safe because wasm-runtime +// rebuilds the per-instance import bridges, so this keeps the fast warm path for the +// common case while bounding linear-memory growth, at the cost of one re-instantiation +// (and bridge recompile) per recycle. +const recycleWarmInstanceAfter = 512 + // Transport can map runtime payloads into call args and map call results back. type Transport interface { Prepare(ctx context.Context, input payload.Payloads) ([]any, error) @@ -55,6 +65,7 @@ type Process struct { waitingYield bool done bool started bool + warmCalls int } // NewProcess creates a scheduler process for WASM execution. @@ -141,7 +152,12 @@ func (p *Process) stepSync(out *process.StepOutput) error { p.result = result p.done = true - p.softReset() + p.warmCalls++ + if p.warmCalls >= recycleWarmInstanceAfter { + p.endExecution() + } else { + p.softReset() + } out.Done(result) return nil } @@ -257,6 +273,7 @@ func (p *Process) endExecution() { _ = p.inst.Close(context.Background()) p.inst = nil } + p.warmCalls = 0 p.softReset() } diff --git a/runtime/wasm/engine/process_transport_test.go b/runtime/wasm/engine/process_transport_test.go index 7c60a99e9..b1a3c46e2 100644 --- a/runtime/wasm/engine/process_transport_test.go +++ b/runtime/wasm/engine/process_transport_test.go @@ -9,10 +9,67 @@ import ( ctxapi "github.com/wippyai/runtime/api/context" "github.com/wippyai/runtime/api/payload" + "github.com/wippyai/runtime/api/process" wasmapi "github.com/wippyai/runtime/api/runtime/wasm" wasmrt "github.com/wippyai/wasm-runtime/runtime" ) +// TestProcessRecyclesWarmInstanceAfterThreshold verifies that a reused warm +// instance is kept across synchronous calls and recycled (closed, so the next +// call instantiates fresh and reclaims linear memory) once it has served +// recycleWarmInstanceAfter calls. This bounds the WASM linear-memory growth that +// otherwise accumulated unbounded across reused calls. +func TestProcessRecyclesWarmInstanceAfterThreshold(t *testing.T) { + ctx := context.Background() + rt, mod := compileEchoModule(ctx, t) + defer func() { _ = rt.Close(ctx) }() + + p := &Process{module: mod, method: "run"} + var out process.StepOutput + + for i := 1; i <= recycleWarmInstanceAfter+1; i++ { + if p.inst == nil { + inst, err := mod.Instantiate(ctx) + if err != nil { + t.Fatalf("call %d: Instantiate() error = %v", i, err) + } + p.inst = inst + } + // softReset clears execCtx after each sync call; restore it like + // startExecution would for the next call. + p.execCtx = ctx + + out.Reset() + if err := p.stepSync(&out); err != nil { + t.Fatalf("call %d: stepSync() error = %v", i, err) + } + + switch { + case i < recycleWarmInstanceAfter: + if p.inst == nil { + t.Fatalf("call %d: warm instance recycled before the threshold", i) + } + if p.warmCalls != i { + t.Fatalf("call %d: warmCalls = %d, want %d", i, p.warmCalls, i) + } + case i == recycleWarmInstanceAfter: + if p.inst != nil { + t.Fatalf("call %d: warm instance not recycled at the threshold", i) + } + if p.warmCalls != 0 { + t.Fatalf("call %d: warmCalls = %d after recycle, want 0", i, p.warmCalls) + } + default: + if p.inst == nil { + t.Fatalf("call %d: expected a fresh warm instance after recycle", i) + } + if p.warmCalls != i-recycleWarmInstanceAfter { + t.Fatalf("call %d: warmCalls = %d, want %d", i, p.warmCalls, i-recycleWarmInstanceAfter) + } + } + } +} + type processTestTransportRegistry struct { items map[string]any }