Skip to content

Runtime Guards

VM safety guards enforce hard limits on script execution. The core invariant: code can die, but the VM must not. A runaway script is killed cleanly — the VM stays operational, the host firmware stays up.

Why Guards Exist

On ESP32, a user script runs on the same chip as the firmware. Without guards:

  • An infinite loop locks the chip — no OTA, no MQTT, no recovery
  • A memory-hungry script exhausts heap — firmware allocations fail
  • A recursive event handler spins the event loop — timers and I/O starve

Guards turn these into traps. Most are recoverable — the script can catch them with try and continue — but the event storm guard is an integrity-level trap that always kills the script (see below). Either way the VM stays operational; the host decides what to do next (retry, backoff, safe mode).

Each guard trap carries a fault code that surfaces on result.code when caught: opcode budget = 10, heap limit = 11, event storm = 12. See error handling for the full code table.

Guard Types

Opcode Budget

Limits the total number of VM instructions executed.

runtime.guard(100000, 0, 0)

The VM increments opcode_total on every instruction. When it exceeds opcode_budget, the script traps with opcode budget exceeded.

This catches:

  • Infinite while loops
  • Deeply recursive functions
  • Any code path that runs too long without yielding

When the trap is caught by try, the counter resets to 0 so execution can continue.

Heap Limit

Limits total heap allocation in bytes.

runtime.guard(0, 65536, 0)

Before trapping, the VM runs a GC cycle. If bytes_allocated is still over heap_limit after collection, the script traps with heap limit exceeded.

This catches:

  • Unbounded array growth
  • String concatenation in loops
  • Any allocation pattern that exceeds available memory

Event Storm Chain

Limits consecutive same-type event emits during a handler.

runtime.guard(0, 0, 5)

The VM tracks which event type is currently being dispatched (evt_dispatch_eid). When a handler emits the same event type it is handling, evt_emits_per_tick increments. When it exceeds evt_max_emits_per_tick, the script traps with event storm detected (fault code 12).

on Ping fn(e: Ping): void {
    emit Ping { n: 0 }    # each emit increments chain counter
}

The chain counter only increments for same-type emits. Emitting a different event type inside a handler is not counted.

Event storm is fatal — not catchable

Unlike the opcode and heap guards, the event storm trap is an integrity-level guard. It is not routed through try: a runaway emit loop always terminates the program, even inside a try frame. A self-feeding event chain is a structural bug, and silently swallowing it would let the event loop starve while looking healthy. The opcode and heap guards remain catchable.

Two-Layer Architecture

Guards operate at two layers:

+---------------------------+
|  Host (C / firmware)      |   star_vm_configure_guards()
|  Sets immutable floor     |   Called before script loads
+---------------------------+
           |
           | floor values
           v
+---------------------------+
|  Script (StarLang)        |   runtime.guard()
|  Can tighten, not loosen  |   Called from user code
+---------------------------+

Host Layer (Floor)

The host calls star_vm_configure_guards() before loading any script:

star_vm_configure_guards(vm, 100000, 65536, 10);

This sets both the active guard values and the floor. The floor is immutable — no script code can weaken it.

Script Layer (Tighten Only)

runtime.guard() can set stricter limits, but never exceed the host floor:

Host floor Script requests Effective value
100000 50000 50000 (tighter)
100000 200000 100000 (clamped to floor)
100000 0 100000 (0 = no change)
0 (no floor) 50000 50000 (no floor to clamp)
0 (no floor) 0 0 (no guard)

The clamping logic in the VM:

case OP_SET_GUARD: {
    // script requests value v
    if (floor > 0 && v > floor)
        v = floor;          // clamp to host floor
    vm->guard = v;
}

A script that tries runtime.guard(0, 0, 0) to disable guards simply has no effect — 0 means "don't change", and the host floor remains active.

Trap Behavior

When a guard trips, the VM looks for a try frame on the call stack:

+------ try frame found ------+------ no try frame ------+
|                              |                          |
|  Unwind to try frame         |  Set raise_msg           |
|  Create failed result        |  Return VM_ERR_*         |
|  Reset counter               |  Script is dead          |
|  Continue execution          |  Host handles cleanup    |
+------------------------------+--------------------------+

Caught by try

runtime.guard(5000, 0, 0)
var r: result<void> = try riskyWork()
if (r.failed) {
    console.log(r.error)    # "opcode budget exceeded"
}
# execution continues normally

After a caught trap:

  • Opcode budget (10): opcode_total resets to 0, so execution continues cleanly.
  • Heap limit (11): GC already ran. Recovery only succeeds if the offending allocations became unreachable when the faulting function unwound — then the next trap-check's GC reclaims them. If the over-limit memory is still rooted after the catch, the next instruction re-trips the guard (fatally, if no enclosing try).
  • Event storm (12): not applicable — this trap is fatal and never reaches a try frame.

Uncaught

If no try frame exists, the guard error propagates to the host as a StarVMResult:

StarVMResult r = star_vm_run(vm);
switch (r) {
    case VM_ERR_OPCODE_BUDGET:
    case VM_ERR_HEAP_LIMIT:
    case VM_ERR_EVENT_STORM:
        // script died — clean VM, decide retry/backoff/safe mode
        break;
}

VM Struct Fields

struct StarVM {
    // active guard values
    uint64_t    opcode_budget;          // 0 = unlimited
    uint64_t    opcode_total;           // running counter
    size_t      heap_limit;             // 0 = unlimited
    uint32_t    evt_emits_per_tick;     // running counter
    uint32_t    evt_max_emits_per_tick; // 0 = unlimited
    int8_t      evt_dispatch_eid;       // -1 = not in handler

    // host floor (immutable from script)
    uint64_t    guard_floor_opcodes;    // 0 = no floor
    size_t      guard_floor_heap;       // 0 = no floor
    uint32_t    guard_floor_emits;      // 0 = no floor
};

C API

// Set guards + floor (host side, before script load)
void star_vm_configure_guards(StarVM *vm,
    uint64_t opcode_budget,
    size_t   heap_limit,
    uint32_t max_emits_per_tick);

ESP32 Startup Check

On ESP32 builds (-DSTAR_PLATFORM_ESP32), star_vm_run() refuses to start if no guards are configured:

// vm.c — top of star_vm_run()
#ifdef STAR_PLATFORM_ESP32
if (vm->opcode_budget == 0 && vm->heap_limit == 0) {
    return VM_ERR_NO_GUARDS;
    // "ESP32 requires guards: call star_vm_configure_guards() before star_vm_run()"
}
#endif

This is a compile-time gated runtime assert. On PC, guards are optional. On ESP32, forgetting guards is a fatal mistake — the check makes it impossible to deploy unprotected.

The check requires at least one of opcode_budget or heap_limit to be non-zero. Event storm guard alone is not sufficient — you need at minimum an instruction or memory bound.

Default Values

All guards default to 0 (unlimited / no guard). On PC this is intentional — development and testing run without limits. On ESP32 the startup check enforces that the host configures guards before any script can execute.

Context Recommended Configuration
PC development No guards (default)
PC production Opcode + heap guards
ESP32 All three guards mandatory

ESP32 Example

star_vm_init(&vm);
star_vm_configure_gc(&vm, 32 * 1024, 1.5, 128 * 1024);
star_vm_configure_guards(&vm, 500000, 48 * 1024, 10);
// load and run script...

Interaction with Other Systems

System Interaction
GC Heap guard triggers GC before trapping — if GC frees enough, execution continues
Tasks Opcode budget counts across all tasks (global counter, not per-task)
Event loop Event storm only counts same-type chain emits during handler dispatch
Timers Timer callbacks are subject to opcode budget
try/catch Opcode and heap guards are catchable via try; the event storm guard is fatal and bypasses try

Opcodes

Opcode Hex Stack Description
OP_SET_GUARD 0xE9 [ops, heap, emits] -> [] Set guard values (clamped to host floor)