Skip to content

Commit be976f7

Browse files
Add an optional per-thread fuel budget
Thread interruption lets a host stop a running program once it decides to act, but it cannot express "this program may run for at most N units of work". A host that shares one machine between many untrusted programs needs that, and today it can only get it from the execution listener, which the compiler does not call. Fuel is consumed inside checkInterruption, so it lands at the points the engine already checks -- backward jumps and calls -- and needs no new emission sites. It is per thread because interruption is per thread, so one program per thread gets one budget per program. Metering is off until a host calls Fuel.set, and while it is off the cost is one static read at points that already do more work than that.
1 parent 7505798 commit be976f7

6 files changed

Lines changed: 229 additions & 1 deletion

File tree

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package run.endive.testing;
2+
3+
import static org.junit.jupiter.api.Assertions.assertEquals;
4+
import static org.junit.jupiter.api.Assertions.assertThrows;
5+
import static org.junit.jupiter.api.Assertions.assertTrue;
6+
7+
import org.junit.jupiter.api.AfterEach;
8+
import org.junit.jupiter.api.Test;
9+
import run.endive.compiler.MachineFactoryCompiler;
10+
import run.endive.runtime.Fuel;
11+
import run.endive.runtime.Instance;
12+
import run.endive.runtime.WasmOutOfFuelException;
13+
import run.endive.wabt.Wat2Wasm;
14+
import run.endive.wasm.Parser;
15+
import run.endive.wasm.WasmModule;
16+
17+
public class FuelTest {
18+
19+
// A loop with no exit condition. Without a budget it never returns.
20+
private static final String ENDLESS = "(module (func (export \"run\") (loop $l (br $l))))";
21+
22+
// A loop that runs a fixed number of times, so a generous budget must let it finish.
23+
private static final String COUNTED =
24+
"(module (func (export \"run\") (result i32) (local $i i32)"
25+
+ " (loop $l (local.set $i (i32.add (local.get $i) (i32.const 1)))"
26+
+ " (br_if $l (i32.lt_s (local.get $i) (i32.const 1000))))"
27+
+ " (local.get $i)))";
28+
29+
@AfterEach
30+
public void stopMetering() {
31+
Fuel.clear();
32+
}
33+
34+
@Test
35+
public void endlessLoopRunsOutOfFuel() {
36+
var run = compiled(ENDLESS).export("run");
37+
38+
Fuel.set(10_000);
39+
assertThrows(WasmOutOfFuelException.class, run::apply);
40+
assertEquals(0, Fuel.remaining());
41+
}
42+
43+
@Test
44+
public void aBudgetedProgramStillFinishes() {
45+
var run = compiled(COUNTED).export("run");
46+
47+
Fuel.set(10_000);
48+
assertEquals(1000, run.apply()[0]);
49+
assertTrue(Fuel.remaining() > 0);
50+
}
51+
52+
@Test
53+
public void withoutABudgetNothingIsMetered() {
54+
var run = compiled(COUNTED).export("run");
55+
56+
assertEquals(Fuel.UNLIMITED, Fuel.remaining());
57+
assertEquals(1000, run.apply()[0]);
58+
assertEquals(Fuel.UNLIMITED, Fuel.remaining());
59+
}
60+
61+
@Test
62+
public void aBudgetAppliesOnlyToTheThreadThatSetIt() throws Exception {
63+
var run = compiled(COUNTED).export("run");
64+
Fuel.set(10);
65+
66+
var other = new Thread(() -> assertEquals(Fuel.UNLIMITED, Fuel.remaining()));
67+
other.start();
68+
other.join();
69+
70+
assertThrows(WasmOutOfFuelException.class, run::apply);
71+
}
72+
73+
@Test
74+
public void theInterpreterIsMeteredToo() {
75+
// Fuel is not a compiler-only feature: an API that silently did nothing on the
76+
// interpreter would be its own trap.
77+
WasmModule module = Parser.parse(Wat2Wasm.parse(ENDLESS));
78+
var run = Instance.builder(module).build().export("run");
79+
80+
Fuel.set(10_000);
81+
assertThrows(WasmOutOfFuelException.class, run::apply);
82+
}
83+
84+
private static Instance compiled(String wat) {
85+
WasmModule module = Parser.parse(Wat2Wasm.parse(wat));
86+
return Instance.builder(module)
87+
.withMachineFactory(MachineFactoryCompiler.compile(module))
88+
.build();
89+
}
90+
}

compiler/src/main/java/run/endive/compiler/internal/Shaded.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import java.util.Arrays;
77
import run.endive.runtime.CallResult;
88
import run.endive.runtime.ConstantEvaluators;
9+
import run.endive.runtime.Fuel;
910
import run.endive.runtime.Instance;
1011
import run.endive.runtime.MemCopyWorkaround;
1112
import run.endive.runtime.Memory;
@@ -483,6 +484,7 @@ public static void checkInterruption() {
483484
if (Thread.currentThread().isInterrupted()) {
484485
throw new WasmInterruptedException("Thread interrupted");
485486
}
487+
Fuel.consume();
486488
}
487489

488490
public static long readGlobal(int index, Instance instance) {

docs/docs/advanced/cpu-limits.md

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ Wasm modules can contain infinite loops. When running untrusted code, always set
1111

1212
Often, when running untrusted user code in our infrastructure, we want to have strong guarantees around the termination of the program.
1313

14-
To achieve this result there are, currently, two mechanisms in Endive:
14+
To achieve this result there are, currently, three mechanisms in Endive:
1515

1616
## Interrupts
1717

@@ -83,6 +83,35 @@ var instance =
8383
System.out.println("current instruction: " + instruction + ", stack size: " + stack.size())).build();
8484
```
8585

86+
## Fuel
87+
88+
Interrupts let you stop a program once you decide to act, but they cannot express "this program may run for at most so much work". Fuel can: you give the current thread a budget of work, and execution stops with a `WasmOutOfFuelException` once it is spent.
89+
90+
A budget is spent by doing work rather than by time passing, so it does not move with how busy the machine is: the same module given the same fuel gets the same distance every time.
91+
92+
```java
93+
import run.endive.runtime.Fuel;
94+
import run.endive.runtime.WasmOutOfFuelException;
95+
96+
Fuel.set(1_000_000);
97+
try {
98+
function.apply();
99+
} catch (WasmOutOfFuelException e) {
100+
// the module outstayed its budget
101+
} finally {
102+
Fuel.clear();
103+
}
104+
```
105+
106+
Fuel is consumed wherever the engine already checks for interruption — backward jumps and calls — so it is not per-instruction accounting. It is enough to bound a loop that would otherwise never end, and unlike the execution listener it keeps working when the module is run through the compiler.
107+
108+
The budget belongs to the thread that set it, because interruption does too, so running one module per thread gives you one budget per module with no further bookkeeping.
109+
110+
Fuel bounds work, not memory. It will stop a module that computes forever; it will not stop one that allocates heavily, which is a separate problem with separate mechanisms.
111+
112+
Modules that never use fuel are unaffected. Metering is off until some thread calls `Fuel.set`, and while it is off the check reads one static field, on a path that already does more work than that.
113+
114+
86115
<!--
87116
```java
88117
docs.FileOps.writeResult("docs/advanced", "cpu-limits.md.result", "empty");
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package run.endive.runtime;
2+
3+
/**
4+
* An optional per-thread execution budget.
5+
*
6+
* <p>A host that runs untrusted Wasm needs to stop a program that never finishes. Thread
7+
* interruption already does that when the host decides to act, but it cannot express "this program
8+
* may run for at most N units of work", which is what a scheduler sharing one machine between many
9+
* programs needs.
10+
*
11+
* <p>Fuel is consumed wherever the engine already checks for interruption: backward jumps and
12+
* calls. That is not per-instruction accounting, and it is not meant to be — it is enough to bound
13+
* a loop that would otherwise never end, and it costs nothing at the points in between.
14+
*
15+
* <p>Fuel bounds work, not memory. It will stop a module that computes forever; it will not stop
16+
* one that allocates heavily.
17+
*
18+
* <p>Fuel is per thread because interruption is per thread, so a host that runs one program per
19+
* thread gets one budget per program with no further bookkeeping:
20+
*
21+
* <pre>{@code
22+
* Fuel.set(1_000_000);
23+
* try {
24+
* instance.export("_start").apply();
25+
* } catch (WasmOutOfFuelException e) {
26+
* // the program outstayed its budget
27+
* } finally {
28+
* Fuel.clear();
29+
* }
30+
* }</pre>
31+
*
32+
* <p>Metering is off unless a host turns it on, and while it is off the only cost is one static
33+
* read at points that already do more work than that.
34+
*/
35+
public final class Fuel {
36+
37+
/** The value {@link #remaining()} reports when this thread is not metered. */
38+
public static final long UNLIMITED = -1;
39+
40+
/**
41+
* False until some thread first sets a budget. Kept as a fast path so hosts that never meter
42+
* do not pay for a thread-local lookup on every backward jump.
43+
*/
44+
private static volatile boolean enabled;
45+
46+
private static final ThreadLocal<long[]> REMAINING =
47+
ThreadLocal.withInitial(() -> new long[] {UNLIMITED});
48+
49+
private Fuel() {}
50+
51+
/**
52+
* Consumes one unit on the current thread, throwing {@link WasmOutOfFuelException} once the
53+
* budget is exhausted. Called by the engine; hosts do not need to call it.
54+
*/
55+
public static void consume() {
56+
if (!enabled) {
57+
return;
58+
}
59+
long[] cell = REMAINING.get();
60+
long remaining = cell[0];
61+
if (remaining == UNLIMITED) {
62+
return;
63+
}
64+
if (remaining == 0) {
65+
throw new WasmOutOfFuelException("Out of fuel");
66+
}
67+
cell[0] = remaining - 1;
68+
}
69+
70+
/** Gives the current thread a budget of {@code units}, replacing any budget it already had. */
71+
public static void set(long units) {
72+
if (units < 0) {
73+
throw new IllegalArgumentException("Fuel budget must not be negative");
74+
}
75+
enabled = true;
76+
REMAINING.get()[0] = units;
77+
}
78+
79+
/** Stops metering the current thread. */
80+
public static void clear() {
81+
REMAINING.get()[0] = UNLIMITED;
82+
}
83+
84+
/** What is left on the current thread, or {@link #UNLIMITED} when it is not metered. */
85+
public static long remaining() {
86+
return REMAINING.get()[0];
87+
}
88+
}

runtime/src/main/java/run/endive/runtime/InterpreterMachine.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3412,6 +3412,7 @@ private static void checkInterruption() {
34123412
if (Thread.currentThread().isInterrupted()) {
34133413
throw new WasmInterruptedException("Thread interrupted");
34143414
}
3415+
Fuel.consume();
34153416
}
34163417

34173418
// ===== GC opcode implementations =====
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package run.endive.runtime;
2+
3+
import run.endive.wasm.WasmEngineException;
4+
5+
/** Thrown when a running Wasm execution exhausts the fuel budget set by the host. */
6+
public class WasmOutOfFuelException extends WasmEngineException {
7+
public WasmOutOfFuelException(String msg) {
8+
super(msg);
9+
}
10+
11+
public WasmOutOfFuelException(Throwable cause) {
12+
super(cause);
13+
}
14+
15+
public WasmOutOfFuelException(String msg, Throwable cause) {
16+
super(msg, cause);
17+
}
18+
}

0 commit comments

Comments
 (0)