Suspending anywhere: fuel and host calls
A codemode script may loop forever by mistake, wait minutes for a tool, or be cancelled.
The host must be able to take control back at any moment, and, while the script waits,
to save it and continue it later somewhere else. The goal to suspend
anywhere is that execution can stop between any two
instructions: in the middle of a map callback, inside a getter, in a promise job,
three generators deep. This chapter shows what stops a run, what a stop looks like, and
how generators, async functions and builtins fit in.
In short.
runexecutes until its fuel is used up, a sync host function is called, or there is nothing left to do, and then returns a status. Because the previous chapters put every piece of state in the arena, returning is all a stop takes: there is nothing to unwind and nothing to save. Fuel is a weighted instruction count, and an overshoot carries to the next run, so a budget stops at the same instruction however it is sliced. Generators and async functions suspend by copying their frame into a heap object.
What a stop looks like
The host calls run with an amount of fuel. It returns one of:
| status | meaning | the host then |
|---|---|---|
Done(v) | the call returned and nothing is left: no jobs, no pending async host calls | reads v |
Threw(e) | an uncaught exception, of the call or of a job | reads e |
OutOfFuel | the fuel ran out at an instruction boundary | runs again, or snapshots, or gives up |
HostCall | a sync host function was called; the VM is parked on it | handles it, resolves it, runs again |
Idle | nothing to run, but async host calls are pending | delivers a result, runs again |
HostOutOfMemory | the host allocator refused memory (D1690) | discards the call; the VM is still usable |
In every case, the interpreter’s registers have been written to the Interp cell, and
the arena is a complete description of the VM (D1151). A snapshot is a copy of it;
resuming in a fresh VM restored from that copy continues exactly where the old one
stopped. The examples in this guide show it: every output on these pages was produced by
a VM that was stopped every 50 units of fuel, copied into an image, and restored into a
new VM before it continued. On the docs site, the Suspend & snapshot tab of a live
example does this in your browser and shows the slices.
const words = ["fuel", "frames", "arena", "image"];
const lengths = words.map((w) => {
let n = 0;
for (const ch of w) n++; // a loop inside a callback inside a builtin
return n;
});
console.log(lengths.join(" "), lengths.reduce((a, b) => a + b, 0));
4 6 5 5 20
function 0 <anonymous>: params=2 locals=3 stack=7 captures=0
handler [3, 103) -> 103 depth 0
0 async_start loc0 (<state>)
3 push_const #0 ("fuel")
8 push_const #1 ("frames")
13 push_const #2 ("arena")
18 push_const #3 ("image")
23 array_from 4
26 set_loc loc1 (words) ; 2:17
29 get_method "map" ic0 ; 2:22
36 fclosure child0
41 call 1 ; 2:26
44 put_loc loc2 (lengths)
47 get_arg arg1 ; 7:1
50 get_method "log" ic1 ; 7:8
57 get_loc loc2 (lengths) ; 7:13
60 get_method "join" ic2 ; 7:20
67 push_const #4 (" ")
72 call 1 ; 7:25
75 get_loc loc2 (lengths) ; 7:32
78 get_method "reduce" ic3 ; 7:39
85 fclosure child1
90 push_i8 0
92 call 2 ; 7:46
95 call 2 ; 7:12
98 drop
99 undefined
100 async_return loc0 (<state>)
103 async_reject loc0 (<state>)
function 0.0 <anonymous>: params=1 locals=2 stack=4 captures=0
handler [42, 48) -> 26 depth 2
0 push_i8 0
2 put_loc loc0 (n)
5 get_arg arg0 ; 4:20
8 call_intrinsic %GetIterator 1 ; 4:3
13 dup
14 get_field "next" ic0
21 jump -> 48
26 nip
27 swap
28 call_intrinsic %IteratorCloseThrow 1
33 drop
34 throw
35 get_field "value" ic1
42 put_loc loc1 (ch) ; 4:14
45 inc_loc loc0 (n) ; 4:24
48 dup2 ; 4:3
49 iter_next -> 42
54 iter_check_object
55 dup
56 get_field "done" ic2
63 jump_if_false -> 35
68 drop
69 drop
70 drop
71 get_loc loc0 (n) ; 5:10
74 return
75 return_undef
function 0.1 <anonymous>: params=2 locals=0 stack=2 captures=0
0 get_arg arg0 ; 7:57
3 get_arg arg1 ; 7:61
6 add ; 7:59
7 return
Run the example to walk the VM's arena.
Run the example to see it suspend and snapshot.
Fuel
Fuel is a counter that every instruction decrements by its cost before it runs. The costs come in five classes, in the instruction table:
| class | fuel | instructions |
|---|---|---|
Free | 0 | pseudo-ops that never execute, nop |
Basic | 1 | stack shuffles, locals, constants, arithmetic |
Access | 2 | property, global and iterator access |
Alloc | 3 | instructions that allocate a cell |
Call | 5 | calls and constructs |
Work that is not one instruction is charged in proportion to what it does: a leaf
intrinsic that copies a string charges for the units it copies, JSON.parse for the
bytes it reads, the regex matcher for every step it takes (D1711). A @native builtin
charges 4 per loop iteration and 2 per call it makes (D1633). So fuel bounds the work
a slice does, not just its instruction count: one instruction that parses a large tool
result costs as much fuel as the work it does.
An instruction is charged before it runs, so a slice can end with the fuel below zero
when its last instruction was expensive. That overshoot is stored in the heap as a debt
(Interp::FUEL_DEBT) and paid first by the next run (D1740, D1712). The result: a
budget of a million fuel stops at the same instruction whether it was given as one run
or as a thousand slices of a thousand, with or without snapshots in between.
Trade-off. Fuel over wall-clock time. Time is what the host cares about, but a time limit makes the stopping point depend on the machine, its load, and V8’s mood, so the same script with the same inputs could time out on one run and not the next. Fuel makes the stopping point a function of the program. The cost: fuel only approximates time, the weights are coarse, and every new native operation has to charge for its work or it becomes a way to stall the host. Hosts that need a time limit (pi does) poll the clock between slices: pi runs 10,000 fuel per poll (D1492).
Safepoints
Stopping for fuel can happen at any instruction boundary. Collecting garbage can happen only at safepoints, the subset of boundaries where the interpreter checks whether a collection is pending: backward jumps, frame entry and return, the start of a run, and the state transitions of native builtins (D1152). At a safepoint, every live value is on the value stack or in a heap cell, the interpreter’s cached offsets into the code are re-derived afterwards, and Rust code holds no raw values. Between safepoints allocation only bumps (see the arena), which is what lets Rust code keep heap values in local variables.
Collection points depend only on the program, not on how it was sliced: the threshold is in the heap and the arena’s growth does not depend on fuel (D1710). That is why the images of two runs with different slicing are byte for byte the same, which the gate checks (D1720; Determinism).
Host calls
A sync host function (console.log in pi, text(), a blocking tool) parks the VM.
The call instruction records a pending call in the heap with its arguments, saves the
registers as if a JavaScript callee had been entered, and run returns HostCall. The
host reads the arguments, does whatever it needs (asynchronously on its side, if it
likes), and resolves the call: the result is pushed where a JavaScript callee’s return
value would go, and the next run continues after the call instruction (D1152). An
error result is thrown at the call instruction, so the script can catch it.
An async host function (tools.* in pi) does not park. The call creates a promise,
records {call id, function, arguments, promise} in the pending host call table, and
continues. The host collects new calls after each run. Their results go into an
inbox in the heap and are applied one at a time, each when the job queue has run
empty, like I/O callbacks in an event loop (D1374, D1670). So what a script does depends
on the order of the results, never on when they arrived relative to the fuel slices.
Both kinds are heap state, so a VM that waits for ten tools snapshots like any other. After a restore, the host decides whether to issue the calls again or to deliver recorded results. The host interface has the protocol from the host’s side.
Generators and async functions
A generator’s frame must outlive the call that created it, and an async function’s frame
must survive every await. djs does not give them separate stacks. When a generator
yields or an async function awaits, the interpreter copies the frame’s slice of the
value stack (arguments, header, locals, operand stack) into a Generator object in the
heap, and pops the frame. Resuming copies the slice back onto the top of the stack and
rewrites the four header words that link it to its new caller (D1375). A frame contains
no absolute positions besides those links, so it can be resumed at any depth.
async function twice(p) {
const x = await p;
return x * 2;
}
return twice(Promise.resolve(21));
function 0 <anonymous>: params=2 locals=2 stack=5 captures=0
handler [3, 44) -> 44 depth 0
0 async_start loc0 (<state>)
3 fclosure child0
8 put_loc loc1 (twice)
11 get_loc loc1 (twice) ; 5:8
14 undefined
15 get_global "Promise" ic0 ; 5:14
22 get_method "resolve" ic1 ; 5:21
29 push_i8 21
31 call 1 ; 5:29
34 call 1 ; 5:13
37 async_return loc0 (<state>)
40 undefined
41 async_return loc0 (<state>)
44 async_reject loc0 (<state>)
function 0.0 twice: params=1 locals=2 stack=2 captures=0
handler [3, 22) -> 22 depth 0
0 async_start loc0 (<state>)
3 get_arg arg0 ; 2:19
6 await loc0 (<state>) ; 2:13
9 set_loc loc1 (x) ; 3:10
12 push_i8 2
14 mul ; 3:12
15 async_return loc0 (<state>)
18 undefined
19 async_return loc0 (<state>)
22 async_reject loc0 (<state>)
In twice, async_start creates the async state object in a hidden local (<state>).
await saves the frame into it and replaces itself with a call of the builtin
%AsyncAwait, a tail call that subscribes the state to the promise and returns the
function’s result promise to the caller. When the promise settles, a promise job resumes
the state, and await pushes the value. Since closures capture boxes and never point
into frames (D1083), nothing else needs to happen.
function* counter() {
let i = 0;
while (true) yield i++;
}
const g = counter();
console.log(g.next().value, g.next().value, g.next().value);
const squares = await Promise.all([1, 2, 3].map(async (x) => {
await null;
return x * x;
}));
console.log(squares.join(" "));
0 1 2 1 4 9
function 0 <anonymous>: params=2 locals=4 stack=6 captures=0
handler [3, 178) -> 178 depth 0
0 async_start loc0 (<state>)
3 fclosure child0
8 put_loc loc1 (counter)
11 get_loc loc1 (counter) ; 5:11
14 undefined
15 call 0 ; 5:18
18 put_loc loc2 (g)
21 get_arg arg1 ; 6:1
24 get_method "log" ic0 ; 6:8
31 get_loc loc2 (g) ; 6:13
34 get_method "next" ic1 ; 6:14
41 call 0 ; 6:19
44 get_field "value" ic2 ; 6:21
51 get_loc loc2 (g) ; 6:29
54 get_method "next" ic3 ; 6:30
61 call 0 ; 6:35
64 get_field "value" ic4 ; 6:37
71 get_loc loc2 (g) ; 6:45
74 get_method "next" ic5 ; 6:46
81 call 0 ; 6:51
84 get_field "value" ic6 ; 6:53
91 call 3 ; 6:12
94 drop
95 get_global "Promise" ic7 ; 7:23
102 get_method "all" ic8 ; 7:30
109 push_i8 1
111 push_i8 2
113 push_i8 3
115 array_from 3
118 get_method "map" ic9 ; 7:44
125 fclosure child1
130 call 1 ; 7:48
133 call 1 ; 7:34
136 await loc0 (<state>) ; 7:17
139 put_loc loc3 (squares)
142 get_arg arg1 ; 11:1
145 get_method "log" ic10 ; 11:8
152 get_loc loc3 (squares) ; 11:13
155 get_method "join" ic11 ; 11:20
162 push_const #0 (" ")
167 call 1 ; 11:25
170 call 1 ; 11:12
173 drop
174 undefined
175 async_return loc0 (<state>)
178 async_reject loc0 (<state>)
function 0.0 counter: params=0 locals=2 stack=2 captures=0
handler [25, 26) -> 26 depth 0
0 generator_start loc0 (<state>)
3 push_i8 0
5 put_loc loc1 (i)
8 get_loc loc1 (i) ; 3:22
11 to_numeric
12 dup
13 inc ; 3:23
14 put_loc loc1 (i) ; 3:22
17 yield loc0 (<state>) ; 3:16
20 jump_if_false -> 27
25 throw
26 return
27 drop
28 jump -> 8
33 return_undef
function 0.1 <anonymous>: params=1 locals=1 stack=2 captures=0
handler [3, 22) -> 22 depth 0
0 async_start loc0 (<state>)
3 null
4 await loc0 (<state>) ; 8:3
7 drop
8 get_arg arg0 ; 9:10
11 get_arg arg0 ; 9:14
14 mul ; 9:12
15 async_return loc0 (<state>)
18 undefined
19 async_return loc0 (<state>)
22 async_reject loc0 (<state>)
Run the example to walk the VM's arena.
Run the example to see it suspend and snapshot.
Trade-off. Copying frames over a stack per generator. Separate stacks would make yield and resume free, but a stack is a large growable region per generator, and the collector and snapshots would need to know about many of them. Copying keeps one value stack and makes a suspended generator an ordinary heap object. The cost is a copy of the frame on every
yieldandawaitand back on every resume, proportional to the frame’s size, which is small for the generators and async functions scripts write.
Builtins in the middle
The hardest stops are the ones inside builtins: a map over a million elements, a
sort with a comparator, a regular expression that backtracks. The leaf
rule handles the first two: hand-written Rust never runs user code, and a
builtin that does is either bytecode or a generated state machine whose every resume
point and loop back-edge is a place it can stop (D1631, D1632). The regex engine keeps
its backtracking stack in the arena and can suspend in the middle of a match (D1332), so
catastrophic backtracking is a timeout, not a hang. The remaining leaf operations (a big
join, JSON.parse of a large tool result) do not stop in the middle, but they are
bounded by their input and charge fuel for it.
Trade-off. Long leaf operations are not resumable. A 10 MB
JSON.parseruns to completion inside one instruction and then charges its fuel, overshooting the slice. Making every leaf resumable would need a state machine per operation, as the regex engine has. Since the overshoot is carried as debt, the budget is still exact; only the latency of one slice grows. If that ever matters, the operation gets a resumable state of its own.
Where to look
crates/djs-core/src/interp/run.rs: the run loop, statuses, fuel and safepoints.crates/djs-core/src/interp/run/suspend.rs: generators and async functions;run/promise.rs: jobs, host calls and the inbox.crates/djs-core/src/vm/mod.rs: the embedder’s API (run,resolve_host_call, images).
Decisions on this page:
- D1151: Interpreter state in the arena
- D1152: Host call protocol and fuel semantics
- D1690: Running out of host memory is not a JS out-of-memory error
- D1711: Regexp fuel can overdraw; backreferences are charged in full
- D1633: Fuel per transition; the backend is part of the engine’s identity
- D1740: Fuel overshoot carries to the next run; a suspended intrinsic is charged once
- D1712: A suspended intrinsic’s refund carries to the next run as a credit
- D1492: pi adapter: 10,000 fuel per interrupt poll
- D1710: GC points and raw images do not depend on the fuel slice
- D1720: Determinism checks in the gate: heap images across schedules and builds
- D1374: Async host functions: a promise plus an entry in the pending host call table
- D1670: Async host results are macrotasks: the host inbox
- D1370: The job queue, and when
runreports Done - D1375: Suspendable frames: frame slices in Generator objects, tail calls for await
- D1083: Captured bindings live in heap Box cells
- D1631: Native frames are ordinary frame records with a header bit
- D1632: The bgen Rust backend: states are basic blocks, calls go through call_intrinsic
- D1332: RegExp objects, suspendable matching and regex literals