EUI applications without a browser

07 — Bytecode

Status: normative for version 1 of the chunk format; implemented by crates/eui-vm. The local { } source syntax on the Soli side is not yet specified; today a chunk is written as an assembly list in the view.

A local handler runs on the client without a round trip: hover, toggling, optimistic increments, validation. It is the only code a client ever executes that it did not ship with, and so it is the most constrained thing in the protocol.

1. What a chunk may do

Read and write the component's local state — the props of the mounted root node — set a node's text or a prop, switch the viewer's palette mode, and emit a server event. Nodes are named by key, the atom carried in the node's key field, so a chunk is independent of any one render's ids and is interned once per session. Nothing else.

Key atom 0 names the node the chunk is running on. No node can answer to it otherwise — an unkeyed node is never entered in the key map — so the meaning is unambiguous, and a client MUST resolve it to the node whose handler is being run. This is what self compiles to.

It matters more than a convenience. A chunk that named its own node by key would depend on that key, so a server interning by source would intern one chunk per key: every row of a list carrying the same hover would spend a chunk, and 10-budgets.md allows 4 095 of them. With 0, one source is one chunk however many nodes carry it, and a list of four thousand rows costs exactly one. There is no I/O, no clock, no randomness, no allocation beyond the operand stack and the strings it holds, and no way to reach another component's tree.

A chunk's effect is advisory: the server re-derives everything from its own state on the next round trip, and its next batch overwrites whatever the chunk touched. Authorisation is never local.

2. Delivery

DefChunkBytes := id:varint  bytes:bytes        -- op 0x14, ≤ 64 KiB
DefChunk      := id:varint  hash:32×u8         -- op 0x13, fetched as an asset

Version 1 clients MUST accept DefChunkBytes; DefChunk by hash is the cacheable path for when the asset endpoint exists and MAY be rejected until then. A chunk id is defined once per session like any table entry.

3. Format

chunk := magic:"EUIC"  version:u8=1  max_stack:u8  code:u8...

max_stack is the depth the verifier must confirm; a chunk claiming more than 64 is rejected. code is a sequence of instructions, each an opcode byte and its operands. All varints are as in 02-wire-format.md.

opNameOperandsStackEffect
0x01push_intsvarint→ int
0x02push_stratom:varint→ strthe atom's value
0x03push_boolu8 (0/1)→ bool
0x04loadatom:varint→ anyroot prop, null if absent
0x05storeatom:varintany →set root prop
0x06dupa → a a
0x07popa →
0x08push_floatf64 bits, LE→ floata non-finite literal stops the run
0x10addint int → intwrapping
0x11subint int → intwrapping
0x12mulint int → intwrapping
0x13negint → int
0x14notbool → bool
0x15eqa b → boolsame type and value
0x16ltint int → bool
0x17gtint int → bool
0x18andbool bool → bool
0x19orbool bool → bool
0x1Ato_strany → strints in decimal, bools as true/false, null as ""
0x1Bconcatstr str → str
0x1Cfaddfloat float → float
0x1Dfsubfloat float → float
0x1Efmulfloat float → float
0x1Ffdivfloat float → floata division by zero is an infinity, and stops the run
0x22fnegfloat → float
0x23fminfloat float → float
0x24fmaxfloat float → float
0x25sinfloat → floatradians
0x26cosfloat → floatradians
0x27sqrtfloat → floatthe root of a negative stops the run
0x28to_floatint → floatthe nearest float
0x29to_intfloat → inttowards zero, saturating at the ends
0x20jumpi16relative to the next instruction
0x21jump_if_falsei16bool →
0x30set_textkey:varintstr →the text of the first node whose key is atom key — or, for 0, the node the chunk is on — locally. On an editable node this is also what it now holds: the caret goes to the end and the client's own buffer agrees, the same as a server's SetText (02 §5), so a handler may empty the field it was run from
0x31set_propkey:varint atom:varintany →that node's prop, locally
0x33set_stylekey:varint style:varintpoint that node at a style table id, locally
0x32emitatom:varintqueue a server event named by the atom, payload = the root props
0x34set_modestr →the viewer's palette mode: light, dark, high_contrast, or toggle (light ⇄ dark); any other string stops the run. The viewer's choice made through the app's own control — never provisional, and the server learns it as the next Viewport
0x36set_uniformkey:varint index:varintfloat →write one float of that node's scene uniform block (03 §1.2), locally. Refused, and the run stops, for a node that is not a scene, an index of 8 or more, or a value that is not finite
0x35go_backask to go back, as the platform's own gesture would (06 §1.3). At most one per run: a chunk that asks twice asks once
0x40returnstop

A float is not an integer, and fadd on an integer is a type error like any other. That is deliberate: a chunk that meant 1.0 and wrote 1 is told so at the instruction rather than having the two number types quietly merge, and to_float is one instruction and one unit of fuel.

Every float a chunk keeps is finite. Each of the instructions above that could produce a NaN or an infinity stops the run instead. The wire format already refuses a non-finite Float at decode; this is the one place inside a client that could have made one, and the invariant is worth more than the frame of optimism an abort costs — a chunk's effects are provisional and the server re-derives them anyway (§1), while a NaN in a uniform is a picture that is nowhere for as long as the node lives.

The floats exist for one reason, and it is worth naming so that the next addition has to argue as hard: a scene's uniform block is eight floats (03 §1.2), and a chunk that turns a cube under the pointer has to carry an angle from one frame to the next. Integers could not hold it, and a round trip per frame is what a local handler exists to avoid. There is still no clock: a chunk cannot ask what time it is, and two runs on the same inputs are the same run — which is what the vectors of 09 §6 rest on.

Type errors at run time — add on a string, set_text with an int — abort the chunk. An aborted chunk has no effect on what it had not yet touched and the client SHOULD fall back to the server handler if the node has one.

4. Verification

Before a chunk runs for the first time, the client MUST:

  • reject a chunk over 64 KiB, with a bad magic, or an unknown version;
  • decode every instruction; an unknown opcode or a truncated operand rejects the chunk;
  • confirm every jump lands on an instruction boundary inside code;
  • compute the stack depth along every path with the fixed effects above, rejecting an underflow, a depth above max_stack, or two paths reaching one instruction at different depths;
  • confirm code ends in return or a jump.

A chunk that fails verification is never run, and the session continues: the handler is simply absent.

5. Metering

Each instruction costs one unit of fuel; a run gets 4 096. String concatenation is bounded at 4 KiB per value. Exhausting either aborts the chunk as in §3. There is no wall-clock deadline because there is nothing a chunk can wait on.

6. Dispatch

Handler::Local(chunk) runs the chunk and sends nothing. Handler::LocalThenServer { chunk, name } runs the chunk, then sends the event named name exactly as Handler::Server would, with the same payload. Any emit inside the chunk queues an additional event. All events are sent after the chunk finishes, in order, and none is sent if the chunk aborted.

The effects of a LocalThenServer chunk — every set_text, set_prop and set_style it performs — are provisional: the client shows them at once and puts the previous values back the moment the next server batch arrives, before applying it. The batch's own ops then land on the tree the server actually has, so a server that confirms the change sends it and a server that ignores it sends nothing, and the client agrees with the server either way without a flicker. A Local chunk's effects are not provisional: hover and pressed states are restored by their own events.

Rendered from spec/07-bytecode.md in the repository. The normative text is spec/; where it and a prose page disagree the spec wins, and that is a bug worth reporting. Edit this page