Skip to content

fix(cranelift): charge fuel per region to match the rwasm VM - #11

Merged
dmitry123 merged 2 commits into
v45-patchedfrom
fix/eager-fuel-regions
Sep 10, 2026
Merged

dmitry123 merged 2 commits into
v45-patchedfrom
fix/eager-fuel-regions

Conversation

@dmitry123

@dmitry123 dmitry123 commented Sep 9, 2026 •

Copy link
Copy Markdown
Member

Summary

Aligns Wasmtime's fuel metering with the rwasm VM so that both engines burn the same fuel, stop at the same point and report the same remaining fuel on every path, not only on runs that complete.

Root causes

  • Wasmtime accumulated operator costs in a register and flushed them only at block exits, calls and known traps. rwasm charges every straight-line region in full on entry. A trap, halt or out-of-fuel therefore left different counters behind.
  • Wasmtime checked the limit only on function entry and loop headers, so a leaf function could overrun the limit and still return success, and loops ran one extra iteration before OutOfFuel.

What changed

  • crates/cranelift/src/func_environ.rs: region-based eager charging. A region opens at function entry, every loop header, each if/else arm, after every end and on the fall-through after br_if, mirroring where the rwasm translator emits ConsumeFuel. The first non-zero charge emits fuel += <cost> with a placeholder immediate, publishes the counter, and branches to a cold out-of-fuel block when the injected fuel is exceeded; the immediate is patched with the region total when the region closes. Regions made only of free operators emit nothing.
  • New rwasm_out_of_fuel(vmctx, before) libcall (crates/environ/src/builtin.rs, crates/wasmtime/src/runtime/vm/libcalls.rs): refuels from the reserve and yields when fuel_async_yield_interval is set, exactly like out_of_gas; when nothing is left it restores the pre-charge counter and traps with OutOfFuel, so a refused charge is never applied and Store::get_fuel matches rwasm's remaining fuel.
  • GC array fills (func_environ/gc/enabled.rs) charge one base unit per element written, so a huge array.new_default cannot run for the price of one operator (vanilla only charged this by accident of where it flushed).
  • Syscall fuel: the overflow guard runs before any charge, and the linear/quadratic formulas compute in 32-bit arithmetic that wraps like rwasm's i32 code before extending to 64 bits.
  • crates/cranelift/src/rwasm_fuel.rs: cost constants come from rwasm-fuel-policy; the operator table stays local because this crate pins a different wasmparser.
  • tests/all/fuel.rs: the tests that encoded the old rules now assert the rwasm ones (a region is charged as a whole before it runs, consuming exactly the limit is not out of fuel, a refused charge leaves the remaining fuel untouched); custom_operator_cost is ignored because operator costs are fixed by rwasm-fuel-policy.
  • Versions bumped to 45.0.0-rwasm.2.

Testing

Fork suite (cargo test --test all, unit tests of wasmtime-rwasm and the cranelift crate): the only failures are the ones already failing on v45-patched (component-model tests, wasm_ty_roundtrip, same_import_names_still_distinct, host_always_has_some_stack, and the wasmtime-environ-rwasm unit tests not compiling), none of which touch fuel.

From fluentlabs-xyz/rwasm with this branch patched in ([patch.crates-io]):

  • tests/fuel_alignment.rs, 21 differential cases covering traps mid-region and in callees, disabled float opcodes, exact-limit and overrun boundaries, loops, tail-call recursion, host failures/halts/consumption, reset_fuel, and all three syscall charging modes: all pass on both engines, with identical absolute numbers.
  • Full rwasm suite, e2e spec testsuite (92 groups), snippets, nitro verifier, clippy with all features.
  • Same suites under fpu + full-wasm-mode.
  • cargo fuzz run differential on the non-signal trap path: no findings.

Follow-up

The rwasm side lands separately: unbounded fuel_limit: None on the Wasmtime executor, param_index counted by parameter position with i64 slots handled, unreachable marking after disabled float opcodes, and the dependency bump to 45.0.0-rwasm.2 once this is published.

Wasmtime accumulated operator costs in a register and flushed them only at
block exits, calls and known traps, while the rwasm VM charges every
straight-line region in full when it is entered. The two engines therefore
burned different fuel on any path that did not complete (traps, halts,
out-of-fuel), and Wasmtime could overrun the limit on a leaf function and
still return success.

Fuel metering now mirrors the rwasm translator: a region opens at function
entry, at every loop header, in each `if`/`else` arm, after every `end` and
on the fall-through after `br_if`. The first non-zero charge emits
`fuel += <cost>` with a placeholder immediate, an out-of-fuel trap and a
store; the immediate is patched with the region total when the region
closes. Regions made only of free operators emit nothing.

Out-of-fuel is raised through the new `TRAP_OUT_OF_FUEL` trap placed before
the store, so a charge that does not fit is never applied and
`Store::get_fuel` reports the same remaining fuel as the rwasm store. This
replaces the `out_of_gas` libcall path, which rwasm never used to refuel.

Syscall fuel runs the overflow guard before charging and computes in 32-bit
arithmetic that wraps like the `i32` code rwasm emits, then extends to 64
bits. The cost constants are re-exported from `rwasm-fuel-policy`; the
operator table stays local only because this crate pins a different
`wasmparser`.

Versions bumped to 45.0.0-rwasm.2.
djadjka
djadjka previously approved these changes Sep 9, 2026
Raising out-of-fuel as a plain trap dropped Wasmtime's refuel path, which
broke `fuel_async_yield_interval` and every async test that relies on it.
Region charges that exceed the injected fuel now go through a new
`rwasm_out_of_fuel(vmctx, before)` libcall. It refuels from the store's
reserve and yields when an async interval is configured, exactly like
`out_of_gas`; when nothing is left it puts `before` back into the store and
traps, so a refused charge is still never applied and `Store::get_fuel`
reports what was available before the region.

GC array fills charge one base unit per element written. Vanilla Wasmtime
only did so by accident of where it flushed its pending cost, and the region
scheme lost that, letting a 64 MiB `array.new_default` run for the price of
one operator.

The fuel tests that encoded the old rules now assert the rwasm ones: a
region is charged as a whole before it runs, consuming exactly the limit is
not out of fuel, and a refused charge leaves the remaining fuel untouched.
`custom_operator_cost` is ignored because operator costs are fixed by
`rwasm-fuel-policy`.
@dmitry123
dmitry123 merged commit 2bfbe02 into v45-patched Sep 10, 2026
2 checks passed
@dmitry123
dmitry123 deleted the fix/eager-fuel-regions branch September 10, 2026 05:35
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.

3 participants