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.
The VM increments opcode_total on every instruction. When it exceeds opcode_budget, the script traps with opcode budget exceeded.
This catches:
- Infinite
whileloops - 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.
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.
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).
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:
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_totalresets 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 enclosingtry). - Event storm (
12): not applicable — this trap is fatal and never reaches atryframe.
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) |