Skip to content

workflows/wasm/host: prevent limiter and Wasmtime resource leaks - #2356

Merged
cfal merged 1 commit into
mainfrom
wasm-memory-leak
Sep 3, 2026
Merged

workflows/wasm/host: prevent limiter and Wasmtime resource leaks#2356
cfal merged 1 commit into
mainfrom
wasm-memory-leak

Conversation

@cfal

@cfal cfal commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

fixes resource leaks in the WASM host by explicitly closing module-owned limiters and Wasmtime resources.

confidential-workflows creates a new module per production execution without injecting limiters. the previous fallback limiters started 27 goroutines per module and were never closed.

this change:

  • replaces constant fallback bounds with static limiters
  • closes owned resources on constructor failure and module shutdown
  • keeps fallback limiters module-local so async eviction cleanup can't affect a replacement module
  • closes temporary modules, linkers, and Wasmtime resources deterministically

@cfal
cfal requested a review from a team as a code owner September 3, 2026 10:15
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

👋 cfal, thanks for creating this pull request!

To help reviewers, please consider creating future PRs as drafts first. This allows you to self-review and make any final changes before notifying the team.

Once you're ready, you can mark it as "Ready for review" to request feedback. Thanks!

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📊 API Diff Results

No changes detected for module github.com/smartcontractkit/chainlink-common

View full report

// and local testing has shown that with less than the min, some
// binaries may error sporadically.
modCfg.MaxMemoryMBs = uint64(math.Max(float64(modCfg.MinMemoryMBs), float64(modCfg.MaxMemoryMBs)))
limit := settings.Size(config.Size(modCfg.MaxMemoryMBs) * config.MByte)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed because settings.Size only wrapped the raw bound with a parser and unit:

func Size(defaultValue config.Size) Setting[config.Size] {
s := NewSetting(defaultValue, config.ParseByte)
s.Unit = "By"
return s

this call site constructed a Factory with only Logger, so Settings and Meter were nil:

lf := limits.Factory{Logger: modCfg.Logger}
if modCfg.EnableUserMetricsLimiter == nil {
modCfg.EnableUserMetricsLimiter = limits.NewGateLimiter(false)
}
if modCfg.MaxUserMetricPayloadLimiter == nil {
limit := settings.Size(config.Size(modCfg.MaxUserMetricPayloadBytes))
var err error
modCfg.MaxUserMetricPayloadLimiter, err = limits.MakeUpperBoundLimiter(lf, limit)

GetOrDefault returns DefaultValue immediately when Settings is nil:

// GetOrDefault gets the setting from the Getter for the given Scope, or returns the default value with an error.
func (s *Setting[T]) GetOrDefault(ctx context.Context, g Getter) (value T, err error) {
if g == nil {
return s.DefaultValue, nil
}
str, err := g.GetScoped(ctx, s.Scope, s.Key)
if err != nil || str == "" {
return s.DefaultValue, err
}
value, err = s.Parse(str)
if err != nil {
return s.DefaultValue, err
}
return value, nil

despite that, MakeUpperBoundLimiter started a global updater:

func newBoundLimiter[N Number](f Factory, bound settings.SettingSpec[N], isLowerBound bool) (BoundLimiter[N], error) {
b := &boundLimiter[N]{
updater: newUpdater[N](nil, func(ctx context.Context) (N, error) {
return bound.GetOrDefault(ctx, f.Settings)
}, nil),
key: bound.GetKey(),
scope: bound.GetScope(),
isLowerBound: isLowerBound,
}
b.recordLimit = func(ctx context.Context, n N) { b.recordBound(ctx, n) }
if f.Meter != nil {
if b.key == "" {
return nil, errors.New("metrics require Key to be set")
}
newGauge, newHist := metricConstructors[N](f.Meter, bound.GetUnit())
key := bound.GetKey()
limitGauge, err := newGauge("bound." + key + ".limit")
if err != nil {
return nil, err
}
b.recordBound = func(ctx context.Context, value N, options ...metric.RecordOption) {
limitGauge.Record(ctx, value, options...)
}
usageHist, err := newHist("bound." + key + ".usage")
if err != nil {
return nil, err
}
b.recordUsage = func(ctx context.Context, value N, options ...metric.RecordOption) {
usageHist.Record(ctx, value, options...)
}
deniedHist, err := newHist("bound." + key + ".denied")
if err != nil {
return nil, err
}
b.recordDenied = func(ctx context.Context, value N, options ...metric.RecordOption) {
deniedHist.Record(ctx, value, options...)
}
} else {
b.recordBound = func(ctx context.Context, value N, options ...metric.RecordOption) {}
b.recordUsage = func(ctx context.Context, value N, options ...metric.RecordOption) {}
b.recordDenied = func(ctx context.Context, value N, options ...metric.RecordOption) {}
}
if f.Logger != nil {
b.lggr = logger.Sugared(f.Logger).Named("BoundLimiter").With("key", bound.GetKey())
}
if f.Settings != nil {
if r, ok := f.Settings.(settings.Registry); ok {
b.subFn = func(ctx context.Context) (<-chan settings.Update[N], func()) {
return bound.Subscribe(ctx, r)
}
}
}
if bound.GetScope() == settings.ScopeGlobal {
go b.updateLoop(context.Background())
}

each updater also started cancellation and ticker goroutines:

func (u *updater[N]) updateLoop(ctx context.Context) {
defer close(u.done)
ctx, cancel := u.stopCh.Ctx(context.WithoutCancel(ctx))
defer cancel()
var updates <-chan settings.Update[N]
var cancelSub func()
var c <-chan time.Time
if u.subFn != nil {
updates, cancelSub = u.subFn(ctx)
defer func() { cancelSub() }() // extra func wrapper is required to ensure we get the final cancelSub value
// opt: poll now to initialize
} else {
t := services.TickerConfig{}.NewTicker(pollPeriod)
defer t.Stop()
c = t.C
}

func (s StopRChan) CtxCancel(ctx context.Context, cancel context.CancelFunc) (context.Context, context.CancelFunc) {
go func() {
select {
case <-s:
cancel()
case <-ctx.Done():
}
}()
return ctx, cancel

// NewTicker returns a started Ticker which calls nextDur for each period.
// Ticker.Stop should be called to prevent goroutine leaks.
func NewTicker(nextDur func() time.Duration) *Ticker {
c := make(chan time.Time) // unbuffered so we block and delay if not being handled
t := Ticker{C: c, stop: make(chan struct{}), reset: make(chan struct{})}
go t.run(c, nextDur)
return &t

so these nine constant defaults created 27 goroutines per module to repeatedly resolve the same value. NewUpperBoundLimiter enforces the same bound without that unused update machinery.

callers that need dynamic settings already inject configured limiters through ModuleConfig:

https://github.com/smartcontractkit/chainlink/blob/f05ecfe30b60d0be98aabbb1052657ef352b74d8/core/services/workflows/syncer/v2/handler.go#L836-L848

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

callers that need dynamic settings already inject configured limiters through ModuleConfig:

IIRC we expect all production callers to do this. This other case was only meant to be a non-production fallback. Do we have callers in production that are not injecting limiters?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

confidential-workflows creates a module per production execution without injecting any limiters:

https://github.com/smartcontractkit/chainlink-confidential-compute/blob/19cbd1cb7c4440fee21caddee229a8dba3a14f28/enclave/apps/confidential-workflows/app/wasm.go#L28-L44

WasmFileSpecFactory is another production path that supplies only Logger:

https://github.com/smartcontractkit/chainlink/blob/be67b7b77be4d515be111ddc935696b58edcec53/core/services/job/wasm_file_spec_factory.go#L24-L36

the standard workflow runtime also omits MaxSubscriptionsLimiter.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we set them from confidential compute? Otherwise we cannot reconfigure and we don't get metrics.

The other one seems less important, given that it already has a null logger?


cfg *ModuleConfig
cfg *ModuleConfig
defaultLimiters moduleLimiters

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

used to be written into the caller-owned ModuleConfig:

if modCfg.MaxUserMetricPayloadLimiter == nil {
limit := settings.Size(config.Size(modCfg.MaxUserMetricPayloadBytes))
var err error
modCfg.MaxUserMetricPayloadLimiter, err = limits.MakeUpperBoundLimiter(lf, limit)
if err != nil {
return nil, fmt.Errorf("failed to make metric payload size limiter: %w", err)
}
}
if modCfg.MaxUserMetricNameLengthLimiter == nil {
limit := settings.Int(int(modCfg.MaxUserMetricNameLength))
var err error
modCfg.MaxUserMetricNameLengthLimiter, err = limits.MakeUpperBoundLimiter(lf, limit)
if err != nil {
return nil, fmt.Errorf("failed to make metric name length limiter: %w", err)
}
}
if modCfg.MaxUserMetricLabelsPerMetricLimiter == nil {
limit := settings.Int(int(modCfg.MaxUserMetricLabelsPerMetric))
var err error
modCfg.MaxUserMetricLabelsPerMetricLimiter, err = limits.MakeUpperBoundLimiter(lf, limit)
if err != nil {
return nil, fmt.Errorf("failed to make labels per metric limiter: %w", err)
}
}
if modCfg.MaxUserMetricLabelValueLengthLimiter == nil {
limit := settings.Int(int(modCfg.MaxUserMetricLabelValueLength))
var err error
modCfg.MaxUserMetricLabelValueLengthLimiter, err = limits.MakeUpperBoundLimiter(lf, limit)
if err != nil {
return nil, fmt.Errorf("failed to make label value length limiter: %w", err)
}
}
if modCfg.MemoryLimiter == nil {
// Take the max of the min and the configured max memory mbs.
// We do this because Go requires a minimum of 16 megabytes to run,
// and local testing has shown that with less than the min, some
// binaries may error sporadically.
modCfg.MaxMemoryMBs = uint64(math.Max(float64(modCfg.MinMemoryMBs), float64(modCfg.MaxMemoryMBs)))
limit := settings.Size(config.Size(modCfg.MaxMemoryMBs) * config.MByte)
var err error
modCfg.MemoryLimiter, err = limits.MakeUpperBoundLimiter(lf, limit)
if err != nil {
return nil, fmt.Errorf("failed to make memory limiter: %w", err)
}
}
if modCfg.MaxCompressedBinaryLimiter == nil {
limit := settings.Size(config.Size(modCfg.MaxCompressedBinarySize))
var err error
modCfg.MaxCompressedBinaryLimiter, err = limits.MakeUpperBoundLimiter(lf, limit)
if err != nil {
return nil, fmt.Errorf("failed to make compressed binary size limiter: %w", err)
}
}
if modCfg.MaxDecompressedBinaryLimiter == nil {
limit := settings.Size(config.Size(modCfg.MaxDecompressedBinarySize))
var err error
modCfg.MaxDecompressedBinaryLimiter, err = limits.MakeUpperBoundLimiter(lf, limit)
if err != nil {
return nil, fmt.Errorf("failed to make decompressed binary size limiter: %w", err)
}
}
if modCfg.MaxResponseSizeLimiter == nil {
limit := settings.Size(config.Size(modCfg.MaxResponseSizeBytes))
var err error
modCfg.MaxResponseSizeLimiter, err = limits.MakeUpperBoundLimiter(lf, limit)
if err != nil {
return nil, fmt.Errorf("failed to make response size limiter: %w", err)
}
}
if modCfg.MaxSubscriptionsLimiter == nil {
var err error
modCfg.MaxSubscriptionsLimiter, err = limits.MakeUpperBoundLimiter(lf, cresettings.Default.WASMPollOneoffSubscriptionLimit)
if err != nil {
return nil, fmt.Errorf("failed to make poll_oneoff subscription limiter: %w", err)
}
}

that isn’t safe as EvictableModule retains the same config pointer, the old module closes asynchronously, and a replacement can be constructed while that cleanup is still queued:

https://github.com/smartcontractkit/chainlink/blob/f05ecfe30b60d0be98aabbb1052657ef352b74d8/core/services/workflows/syncer/v2/handler.go#L914

https://github.com/smartcontractkit/chainlink/blob/f05ecfe30b60d0be98aabbb1052657ef352b74d8/core/services/workflows/syncer/v2/evictable_module.go#L131-L150

https://github.com/smartcontractkit/chainlink/blob/f05ecfe30b60d0be98aabbb1052657ef352b74d8/core/services/workflows/syncer/v2/evictable_module.go#L291-L336

keeping defaults per-module means the old module can only close its own resources.

@vreff
vreff requested a review from jmank88 September 3, 2026 11:49
jmank88
jmank88 previously requested changes Sep 3, 2026

@jmank88 jmank88 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you please add a description? I'm having trouble understanding the goal of this PR.

if err != nil {
return nil, fmt.Errorf("failed to make metric payload size limiter: %w", err)
}
defaultLimiters.maxUserMetricPayload = limits.NewUpperBoundLimiter(config.Size(modCfg.MaxUserMetricPayloadBytes))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why switch from the *Make(Factory,* variants? The only effect should be losing logging, which is especially important while we still have "fail open" cases which only log errors rather than return them.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there is no effective logging lost. Factory.Settings is nil, so GetOrDefault always returns the default with a nil error. the fail-open log path only applies to missing tenants on non-global settings, while these defaults are all global. bound violations weren't logged before either.

so NewUpperBoundLimiter preserves the observable behavior; it only removes the unused updater machinery.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like the idea of depending on implementation details that could change but if this is only for the non-production cases then that is probably fine. Can we update the injection from confidential compute?
Otherwise, if we are stuck with using these for production, then something seems misaligned, because we should want overrides and metrics in that case, no?

@cfal
cfal requested a review from jmank88 September 3, 2026 13:01
@cfal
cfal dismissed jmank88’s stale review September 3, 2026 13:02

replied to comments

@cfal
cfal added this pull request to the merge queue Sep 3, 2026
Merged via the queue into main with commit a8f860e Sep 3, 2026
33 checks passed
@cfal
cfal deleted the wasm-memory-leak branch September 3, 2026 17:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants