bytecodealliance / bytecodealliance/wasmtime

Fuel charges for const-expr initialization are dropped when the module has no `start` function

Open
#14,203 0 comments 0 reactions 0 assignees View on GitHub
bug wasmtime:fuel
Dominant language
Rust
Stars
18.6k
Forks
1.8k
Avg merge
1d 18h
Merged PRs (30d)
126

Description

### Test Case

A module whose only "complicated" global initializer is a three-op extended const-expr. `ConstExpr::const_eval` only folds a single `*.const` op, so `i32.add` is deferred to the synthesized module-startup function (`FuncKey::ModuleStartup`):

```wat
(module
(global i32 (i32.add (i32.const 1) (i32.const 2)))
(export "g2" (global 0)))
```

The module declares no `start` function.

### Steps to Reproduce

```rust
// Cargo.toml: [dependencies] wasmtime = "48"
use wasmtime::*;

const MODULE: &str = r#"(module
(global i32 (i32.add (i32.const 1) (i32.const 2)))
(export "g2" (global 0)))"#;

const MODULE_WITH_START: &str = r#"(module
(func $s)
(start $s)
(global i32 (i32.add (i32.const 1) (i32.const 2)))
(export "g2" (global 0)))"#;

fn measure(wat: &str) -> Result {
let mut config = Config::new();
config.consume_fuel(true);
let engine = Engine::new(&config)?;
let module = Module::new(&engine, wat)?;
let mut store = Store::new(&engine, ());
store.set_fuel(u64::MAX)?;

let instance = Instance::new(&mut store, &module, &[])?;
let v = instance
.get_global(&mut store, "g2")
.unwrap()
.get(&mut store)
.i32();
assert_eq!(v, Some(3), "initializer did not run");

Ok(u64::MAX - store.get_fuel()?)
}

fn main() -> Result<()> {
println!("without start: consumed = {}", measure(MODULE)?);
println!("with start: consumed = {}", measure(MODULE_WITH_START)?);
Ok(())
}
```

Output:

```text
without start: consumed = 1
with start: consumed = 6
```

Reproduced on `main` (`ffb04089ea`) and `release-48.0.0`, Linux x86_64.

### Expected Results

The const-expr runs during instantiation, so its work must be metered. Its three ops each cost `1` with the default `OperatorCost`, plus the startup function's flat entry cost of `1`, so `without start` should consume `4`. The `with start` control adds one call-site charge in the startup function and one entry charge in `$s`, hence `6`.

### Actual Results

`without start` consumes `1` — only the flat entry cost of the startup function. The three const-expr op charges are accumulated into the translator's `fuel_consumed` buffer but never flushed into `fuel_var`, so they are discarded when the function exits. The `with start` control consumes `6`, proving the work is real but only gets metered because `module_start` happens to flush the buffer before the call.

### Versions and Environment

Wasmtime version or commit: `main` at `ffb04089ea` (also `release-48.0.0`)

Operating system: Linux

Architecture: x86_64

### Extra Info

With `consume_fuel(true)`, instantiation-time const-expr work is silently un-metered when there is no `start` function: `translate_const_expr` buffers each op's charge into `fuel_consumed`, the module-startup function never flushes that buffer into `fuel_var`, and `fuel_function_exit` saves `fuel_var` without flushing the leftover. A `start` function happens to flush it via `module_start`, which is why the control shows `6`.

**Impact:** a metering/DoS accounting bypass — large `array.new_fixed`, many element-segment expressions, or long initializer chains can run at essentially zero fuel during instantiation. Affects all init paths routed through `translate_const_expr` (complicated globals, table fills, active/passive element segments, non-static memory-segment offsets).

**Suggested fix:** flush leftover `fuel_consumed` before saving `fuel_var`, e.g. call `fuel_increment_var` at the end of module-startup translation or inside `fuel_function_exit`.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.