Limiting Plugin CPU and Memory Use

This guide answers one task: give a plugin a budget for time, memory and host calls, enforce it, and turn an exceeded budget into a clear error rather than a hung request or an exhausted machine.

Prerequisites

  • [ ] A plugin host, hand-rolled or framework-based.
  • [ ] Wasmtime 24+ if you are enforcing limits on a server.
  • [ ] A worker in the browser, since that is the only interruption mechanism available there.
  • [ ] Fixture modules that loop forever and allocate forever.

Four budgets, four mechanisms

Resource control is not one setting. Four distinct things can run away, each with its own mechanism, and a plugin system that bounds three of them has an outage waiting on the fourth.

Execution time is bounded by pre-emption: terminating a worker in a browser, or fuel and epoch interruption in a server runtime. Memory is bounded at instantiation by a maximum on the Memory object or a store limiter. Host calls are bounded by the host counting them and refusing to serve past a budget. Accumulated output — logs, results, emitted events — is bounded by the host capping what it keeps.

Only the first two are enforced by the runtime. The other two are yours, and they are the ones most often forgotten, because a plugin that exhausts them harms the host rather than itself.

Who enforces what Time and memory limits are enforced by the runtime through pre-emption and instantiation limits. Host call rate and accumulated output are enforced only by the host application, and are the two most often missing. enforced by the runtime time — fuel, epochs, worker termination memory — maximum pages, store limiter enforced only by you host call count and rate accumulated logs, results, events a plugin that exhausts the right-hand pair damages the host, not itself — which is why the runtime cannot help Write all four down as numbers in configuration, not as assumptions in code, so they can be reviewed and tuned per deployment.

Fuel: a deterministic work budget

Wasmtime’s fuel metering charges each instruction against a budget. When it runs out, execution traps with an identifiable error. Because the cost is instruction-based rather than time-based, the same input consumes the same fuel on every machine — which makes it suitable for anything that must behave identically across nodes.

let mut config = Config::new();
config.consume_fuel(true);
let engine = Engine::new(&config)?;

let mut store = Store::new(&engine, HostState::default());
store.set_fuel(20_000_000)?;                       // tuned from measurement, not guessed

let run = instance.get_typed_func::<(i32, i32), i64>(&mut store, "run")?;
match run.call(&mut store, (ptr, len)) {
    Ok(packed) => handle(packed),
    Err(e) if e.downcast_ref::<Trap>() == Some(&Trap::OutOfFuel) => reject("work budget exceeded"),
    Err(e) => fault(e),
}
let used = 20_000_000 - store.get_fuel()?;          // report it; tune from real distributions

Choosing the number is empirical: run your fixtures, observe the fuel used by legitimate plugins, and set the budget several times the observed p99. Reporting the consumption per invocation is what makes later tuning possible, and it also identifies which plugins are expensive long before they hit the ceiling.

Fuel costs throughput — typically 5–15% — because every block is instrumented. That is the price of determinism, and for a request path where wall-clock behaviour is what matters, epochs are cheaper.

Epochs: a cheap wall-clock deadline

Epoch interruption checks a counter at block boundaries and traps when the store’s deadline has passed. A background thread increments the counter on a timer, so the overhead is a comparison rather than bookkeeping on every instruction.

config.epoch_interruption(true);
let engine = Engine::new(&config)?;

// a ticker thread owns the clock
let e = engine.clone();
std::thread::spawn(move || loop {
    std::thread::sleep(std::time::Duration::from_millis(10));
    e.increment_epoch();
});

let mut store = Store::new(&engine, HostState::default());
store.set_epoch_deadline(150);                     // 150 ticks × 10 ms = 1.5 s

The granularity is the tick interval, so a 10 ms ticker gives you deadlines accurate to about 10 ms — far better than needed for a plugin budget and far cheaper than fuel. Use epochs for the timeout and fuel only where you also need determinism.

Memory: bound it at instantiation

Whichever runtime you use, the memory limit must be in place before the module runs, because a module’s start section executes at instantiation and can allocate.

struct Limits { max_bytes: usize }
impl ResourceLimiter for Limits {
    fn memory_growing(&mut self, _cur: usize, desired: usize, _max: Option<usize>) -> Result<bool> {
        Ok(desired <= self.max_bytes)
    }
    fn table_growing(&mut self, _cur: u32, desired: u32, _max: Option<u32>) -> Result<bool> {
        Ok(desired <= 10_000)
    }
}
store.limiter(|s| &mut s.limits);

In a browser the equivalent is supplying the Memory yourself with a maximum, which requires the plugin interface to import memory rather than export it. Bounding the table matters too: a module that grows its function table without limit consumes host memory just as effectively as one that grows its linear memory.

Fuel or epochs, depending on what you need Fuel charges per instruction and is deterministic across machines at a measurable throughput cost. Epoch interruption checks a counter at block boundaries, costs almost nothing, and bounds wall-clock time instead. fuel charged per instruction identical on every machine 5–15% throughput cost use when determinism matters epoch interruption counter checked at block edges wall-clock, tick granularity under 1% overhead use for ordinary request handling They compose: epochs for the deadline, fuel for a work budget where two nodes must agree on the outcome.

Budgets the host must enforce itself

Counting host calls is a few lines and closes a hole nothing else covers. A plugin calling log in a tight loop cannot exhaust its own memory — it exhausts yours.

function hostFns(state) {
  return {
    log: (ptr, len) => {
      if (++state.calls > state.maxCalls) throw new Error('host call budget exceeded');
      if (state.logBytes >= state.maxLogBytes) return;            // silently stop keeping
      const s = readUtf8(state.memory, ptr, Math.min(len, 4096));
      state.logBytes += s.length;
      state.sink.push(s);
    },
  };
}

Throwing from a host function traps the plugin, which is the correct outcome for a budget breach — it stops immediately and the host learns why. Note the distinction between the two limits: the call budget traps, while the log accumulation limit silently stops storing, because truncating output is a less drastic response than killing an otherwise well-behaved plugin.

Report limits as errors people can act on

A limit breach should produce a message that tells the plugin author what to change and the operator what happened. “Error” tells nobody anything.

plugin acme-formatter 1.4.0
  rejected: work budget exceeded (20,000,000 fuel)
  typical usage for this plugin: p50 1.2M, p99 4.8M
  suggestion: the input was 40× larger than usual; consider batching

Publishing the typical distribution alongside the breach converts a mysterious failure into an actionable one. It also tells you when a limit is wrong: if legitimate plugins regularly consume 80% of the budget, the budget is too tight and the next unusual input will trip it.

Expected output

Instrumented limits produce a per-invocation record worth keeping:

plugin      formatter        abi 3
fuel used   1,204,881 / 20,000,000
epoch       0.31 s / 1.50 s deadline
memory peak 38 / 256 pages
host calls  12 / 500
output      8.2 kB / 1 MB cap
verdict     ok

Track the ratios rather than the absolutes. A plugin that has crept from 5% to 60% of its fuel budget over three releases is heading for a failure, and the graph shows it long before a user does.

What each limit actually stops No limits means a plugin can hang or exhaust the host. A memory ceiling stops one failure mode; only an execution budget stops an infinite loop. no limits an infinite loop hangs the host thread until the process dies memory ceiling only allocation fails cleanly the loop still hangs fuel + memory ceiling trapped at the budget the host logs it, drops the instance and carries on Fuel costs a few percent of throughput, which is a small price for a host that cannot be hung. An epoch deadline is the cheaper alternative where the host can afford a coarser granularity.

Gotchas

  • Limits set after instantiation. A start section runs first. Configure the store and memory before instantiating.
  • Fuel budget guessed. Measure legitimate plugins and set several times the p99.
  • Epoch ticker not started. The deadline never arrives and the limit silently does nothing. Test it with a looping fixture.
  • No table limit. Growing a function table consumes host memory too.
  • Host call budget missing. The one runaway the runtime cannot see.
  • Limits per invocation but not per tenant. A caller that invokes a plugin ten thousand times has bypassed every per-call budget you set.

Performance note

Measured on a server workload: epoch interruption added under 1% to throughput and fuel metering added 11%. A memory limiter costs nothing measurable, since it is consulted only on growth. Host call counting adds a single increment per call, which is irrelevant next to the marshalling around it. The only limit with a real price is fuel, and it buys determinism that nothing else provides.

Frequently Asked Questions

Can I limit a plugin in the browser without a worker? No. There is no way to interrupt a running instance on the same thread, so a deadline requires something you can terminate. Memory limits work anywhere, but time limits require the worker.

What is a reasonable default timeout? Start from what your own code would take for the same job and allow an order of magnitude. Most plugin invocations are tens of milliseconds, so budgets between one and two seconds catch runaways without tripping legitimate work.

Should a plugin that hits a limit be disabled? After repeated breaches, yes — a plugin that fails every invocation is a permanent cost with no benefit. Disable it automatically after a few consecutive faults, tell the user which plugin and why, and let them re-enable it once the author ships a fix.

How do I limit a plugin that spends its time inside my host functions? Charge for them. A host function that performs real work — a lookup, a transformation, a fetch from an allowlist — should consume from the same budget as the plugin’s own execution, either by deducting fuel explicitly or by counting against a separate host-work allowance. Otherwise a plugin can do unbounded work while appearing to consume almost none of its own.

Do limits interact with threads? They do, and not always intuitively. A threaded plugin consumes fuel on every thread against separate stores, so a single budget no longer describes the whole invocation. For plugins, single-threaded execution is the simpler and usually correct choice; reserve threading for trusted first-party modules where the accounting is yours to manage.

← Back to Plugin Systems & Extensibility