Skip to content

runtime: meter fuel calls in metered stores - #124

Merged
CharlieTap merged 2 commits into
CharlieTap:mainfrom
Wavesonics:fuel
Sep 26, 2026
Merged

CharlieTap merged 2 commits into
CharlieTap:mainfrom
Wavesonics:fuel

Conversation

@Wavesonics

@Wavesonics Wavesonics commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

chasm has no instruction budget and no way to interrupt a running call. For apps embedding chasm in order to run 3rd party code like plugins that is often a feature they want.

I've implemented an instruction budge by rewriting each module when I load it, to decrement a global counter at every function entry and loop header, which adds eight instructions to every iteration. This costs me about 26% performance degradation.

Integrating a solution directly into Chasm is much more efficient:

A store created with store(meterFuel = true) now compiles one FuelCheck instruction at each function entry and at each loop's branch target, taking from the store's Fuel.

setFuel, remainingFuel, and interrupt drive it: running out traps with FuelExhausted, and an interrupt from another thread traps the running call with Interrupted at its next check. An interrupt made while no call runs is dropped.

Unmetered stores compile no checks, so existing embedders are unaffected.

On a grammar-checking plugin (Harper, compiled to Wasm) checking 40 paragraphs on the JVM:

Fuel metering Time
None 0.83 to 0.88 s
Rewriting workaround 1.12 to 1.16 s
Native fuel 0.94 to 0.97 s

Fixes #123 & #122, possibly fixes #86

* Instantiating a module runs its start function on the same fuel. Call it only while none of the
* store's calls are running. Has no effect on an unmetered store.
*/
fun setFuel(store: Store, fuel: Long) {

@CharlieTap CharlieTap Sep 25, 2026 •

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

I think maybe something like addFuel would be more flexible, as you could top it up rather than replacing the remaining each time. With some sensible checks like:

fun addFuel(
    store: Store,
    amount: Long,
): ChasmResult<Unit, ChasmError.ExecutionError> {
    val fuel = store.store.fuel

    if (amount < 0L || !fuel.metered) {
        return ChasmResult.Error(
            ChasmError.ExecutionError(
                "Fuel must be non-negative and metering must be enabled",
            ),
        )
    }

    fuel.remaining += amount.coerceAtMost(Long.MAX_VALUE - fuel.remaining)
    return ChasmResult.Success(Unit)
}

I don't if its of any use but a resetFuel might be a good complementary function?

* function entry or loop iteration. An interrupt made while no call runs has no effect. False for an
* unmetered store, whose code has no checks to stop at.
*/
fun interrupt(store: Store): Boolean {

@CharlieTap CharlieTap Sep 25, 2026 •

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

I would pull this (and the other interrupt related parts) out and make them a separate PR which is agnostic of metering

@Suppress("UNUSED_PARAMETER") instruction: AdminInstruction.FuelCheck,
fuel: Fuel,
): DispatchableInstruction = DispatchableInstruction { _, _, nextIp ->
if (--fuel.remaining < 0L) {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

I would decrement post check:

if (fuel.remaining <= 0L) {
    throw InvocationException(InvocationError.FuelExhausted)
}
fuel.remaining--

This preserves zero after exhaustion

*/
class Fuel(val metered: Boolean = false) {
/** Unlimited until set. Read and written only by the thread running the store's calls. */
var remaining: Long = Long.MAX_VALUE

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

This can go to zero once we addFuel

if (kind == BlockKind.Loop) {
state.bind(branchTarget)
// At the loop's branch target, so every iteration spends fuel.
state.compiler.fuel?.let { fuel -> state.emit(AdminInstruction.FuelCheck) { FuelCheckDispatcher(it, fuel) } }

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

It would be nice to have this as its own function, like:

internal fun FunctionCompilationContext.emitFuelCheck() {
    val fuel = compiler.fuel ?: return
    emit(AdminInstruction.FuelCheck) {
        FuelCheckDispatcher(it, fuel)
    }
}

* spin: `(loop (br 0))`. count (i32) -> i32: counts its argument down to zero in a loop. recurse:
* calls itself.
*/
internal val FUEL_MODULE = intArrayOf(

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Typically we commit both a .wasm and a .wat fixture rather than embedding the binary directly. That lets me review the source. You could combine these modules into one fixture and call different exports through testRunner, as in [TypeSystemCorrectnessTest](https://github.com/CharlieTap/chasm/blob/main/chasm/src/commonTest/kotlin/io/github/charlietap/chasm/integration/TypeSystemCorrectnessTest.kt#L15-L41).

If testRunner isn’t flexible enough for the fuel setup, you can load and instantiate the fixture directly—[PrepareFunctionTest](https://github.com/CharlieTap/chasm/blob/main/chasm/src/commonTest/kotlin/io/github/charlietap/chasm/integration/PrepareFunctionTest.kt) shows that approach.

import kotlin.test.Test
import kotlin.test.assertEquals

class FuelTest {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Given we move to addFuel we could do with a few extra cases:

  • A fresh metered store has zero fuel and traps on invocation; an exactly sufficient allowance succeeds and leaves zero.
  • addFuel preserves the existing balance, adding zero changes nothing, and additions reaching or exceeding Long.MAX_VALUE saturate correctly.
  • Negative additions and additions to unmetered stores return an error without changing the balance.
  • After exhaustion, adding fuel allows a finite function to complete successfully. The current refill test runs another infinite loop, so it doesn’t quite establish this.
  • A looping start function exhausts fuel during instantiation.

}

/** The fuel a metered store has left, 0 after it ran out; call it only while none of its calls run. */
fun remainingFuel(store: Store): Long = store.store.fuel.remaining

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Similarly this should probably have a check if fuel.metered is set just to steer correct use of the api.

@Wavesonics

Copy link
Copy Markdown
Contributor Author

thanks for all the feedback @CharlieTap I'll address it tonight!

@CharlieTap CharlieTap left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Hey 👋🏼

Thank you for this! I like the approach as It think it's rigorous enough without getting into really granular cost estimation. Just a few comments to steer the API and get the implementation a little more consistent with the existing source

An embedder running untrusted modules had no way to stop one that
loops forever: chasm has no instruction budget. The workaround is
rewriting each module to decrement a global at every function entry
and loop header, which adds eight instructions to every iteration.

A store created with StoreConfig(meterFuel = true) compiles one FuelCheck
instruction at each function entry and at each loop's branch target,
taking a unit from the store's Fuel. A metered store starts with no
fuel; addFuel tops it up, saturating at Long.MAX_VALUE, resetFuel
empties it, and remainingFuel reads it. Each returns an error for an
unmetered store, and addFuel rejects negative amounts. Running out
traps with FuelExhausted and leaves the balance at zero, including
during a module's start function. Unmetered stores compile no checks,
so existing embedders are unaffected.

On a grammar-checking plugin (Harper, compiled to Wasm) checking 40
paragraphs on the JVM, warmed up: 0.83 to 0.88 s unmetered, 1.12 to
1.16 s with the rewriting workaround, and 0.94 to 0.97 s with native
fuel.
@Wavesonics

Wavesonics commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor Author

I think I've addressed all of your feedback. A few notes:

  • I switched store to the config object pattern used elsewhere: store(StoreConfig(meterFuel = true)). The plain store() is unchanged, so the 2.0.0 API is unaffected.
  • I added resetFuel alongside addFuel.
  • The fuel tests load the fixtures directly, like PrepareFunctionTest, since each store needs a config and fuel before the call, which testRunner doesn't support.
  • I pulled interrupt out of this PR. I'll open a separate PR stacked on this one with a different approach that doesn't depend on metering and is turned on through StoreConfig.

@Wavesonics Wavesonics changed the title runtime: meter fuel and interrupt calls in metered stores runtime: meter fuel calls in metered stores Sep 26, 2026
@Wavesonics Wavesonics mentioned this pull request Sep 26, 2026
CharlieTap
CharlieTap previously approved these changes Sep 26, 2026

@CharlieTap CharlieTap left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Thank you for this it's much appreciated! I'm planning a release next weekend so lets see if we can get the interrupt parts over the line, if you need something in the interim I can publish a snapshot for you

@CharlieTap
CharlieTap merged commit 2631455 into CharlieTap:main Sep 26, 2026
13 checks passed
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.

Fuel metering and interrupting a running call Possible to limit resource usage?

2 participants